diff --git a/.env.centrifuge_demo b/.env.centrifuge_demo new file mode 100644 index 00000000..c09e3f8b --- /dev/null +++ b/.env.centrifuge_demo @@ -0,0 +1,6 @@ +ADMIN=0x423420Ae467df6e90291fd0252c0A8a637C1e03f +PRIVATE_KEY=8e3ec2b1affade3231c6a81c299988176e0343dd8ce9f0986d66ed13e0f178d9 +AXELAR_GATEWAY=0x6aF9C075d8C11b9A2CD66bbA801481b3c7A96488 +RPC_URL=https://fullnode.demo.k-f.dev +CHAIN_ID=2031 +ETHERSCAN_KEY=RESKUIN5V7MS5DCB3TZM6NC249B4FBTRZW diff --git a/.env.goerli b/.env.goerli new file mode 100644 index 00000000..a92de5f9 --- /dev/null +++ b/.env.goerli @@ -0,0 +1,8 @@ +ADMIN=0x423420Ae467df6e90291fd0252c0A8a637C1e03f +PRIVATE_KEY=8e3ec2b1affade3231c6a81c299988176e0343dd8ce9f0986d66ed13e0f178d9 +AXELAR_GATEWAY=0xe432150cce91c13a887f7D836923d5597adD8E31 +CENTRIFUGE_FORWARDER=0xe6091b3Cc04584EaDbeCA59f2FdD0D81606f410b +RPC_URL=https://goerli.infura.io/v3/a4ba76cd4be643618572e7467a444e3a +ETHERSCAN_KEY=RESKUIN5V7MS5DCB3TZM6NC249B4FBTRZW +CHAIN_ID=5 +SETUP_TEST_DATA=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b36a6b7b..cacc1de7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,33 +113,33 @@ jobs: coverage-files: ./lcov.info minimum-coverage: 60 # Set coverage threshold. - slither-analyze: - runs-on: "ubuntu-latest" - permissions: - actions: "read" - contents: "read" - security-events: "write" - steps: - - name: "Check out the repo" - uses: "actions/checkout@v3" - with: - submodules: "recursive" - - - name: "Run Slither analysis" - uses: "crytic/slither-action@v0.3.0" - id: "slither" - with: - fail-on: "none" - sarif: "results.sarif" - solc-version: "0.8.21" - target: "src/" - - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@v2 - with: - sarif_file: ${{ steps.slither.outputs.sarif }} + # slither-analyze: + # runs-on: "ubuntu-latest" + # permissions: + # actions: "read" + # contents: "read" + # security-events: "write" + # steps: + # - name: "Check out the repo" + # uses: "actions/checkout@v3" + # with: + # submodules: "recursive" + + # - name: "Run Slither analysis" + # uses: "crytic/slither-action@v0.3.0" + # id: "slither" + # with: + # fail-on: "none" + # sarif: "results.sarif" + # solc-version: "0.8.21" + # target: "src/" + + # - name: Upload SARIF file + # uses: github/codeql-action/upload-sarif@v2 + # with: + # sarif_file: ${{ steps.slither.outputs.sarif }} - - name: "Add Slither summary" - run: | - echo "## Slither result" >> $GITHUB_STEP_SUMMARY - echo "✅ Uploaded to GitHub code scanning" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + # - name: "Add Slither summary" + # run: | + # echo "## Slither result" >> $GITHUB_STEP_SUMMARY + # echo "✅ Uploaded to GitHub code scanning" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/README.md b/README.md index 5a505436..4028c9be 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Liquidity Pools enable seamless deployment of Centrifuge RWA pools on any EVM-co ![Architecture](./assets/architecture.svg) Investors can invest in multiple tranches for each RWA pool. Each of these tranches is a separate deployment of a Liquidity Pool and a Tranche Token. -- [**Liquidity Pool**](https://github.com/centrifuge/liquidity-pools/blob/main/src/LiquidityPool.sol): A [ERC-4626](https://ethereum.org/en/developers/docs/standards/tokens/erc-4626/) compatible contract that enables investors to deposit and withdraw stablecoins to invest in tranches of pools. +- [**Liquidity Pool**](https://github.com/centrifuge/liquidity-pools/blob/main/src/LiquidityPool.sol): An [ERC-7540](https://eips.ethereum.org/EIPS/eip-7540) (extension of [ERC-4626](https://ethereum.org/en/developers/docs/standards/tokens/erc-4626/)) compatible contract that enables investors to deposit and withdraw stablecoins to invest in tranches of pools. - [**Tranche Token**](https://github.com/centrifuge/liquidity-pools/blob/main/src/token/Tranche.sol): An [ERC-20](https://ethereum.org/en/developers/docs/standards/tokens/erc-20/) token for the tranche, linked to a [`RestrictionManager`](https://github.com/centrifuge/liquidity-pools/blob/main/src/token/RestrictionManager.sol) that manages transfer restrictions. Prices for tranche tokens are computed on Centrifuge. The deployment of these tranches and the management of investments is controlled by the underlying InvestmentManager, TokenManager, Gateway, and Routers. @@ -33,5 +33,13 @@ To run all tests locally: forge test ``` +## Audit reports + +| Auditor | Report link | +|---|---| +| Code4rena | [`September 2023 - Code4rena Report`](https://code4rena.com/reports/2023-09-centrifuge) | +| SRLabs | [`September 2023 - SRLabs Report`](https://github.com/centrifuge/liquidity-pools/blob/main/audits/2023-09-SRLabs.pdf) | +| Spearbit | [`October 2023 - Cantina Managed Report`](https://github.com/centrifuge/liquidity-pools/blob/main/audits/2023-10-Spearbit-Cantina-Managed.pdf) | + ## License -This codebase is licensed under [GNU Lesser General Public License v3.0](https://github.com/centrifuge/centrifuge-chain/blob/main/LICENSE). +This codebase is licensed under [GNU Lesser General Public License v3.0](https://github.com/centrifuge/liquidity-pools/blob/main/LICENSE). diff --git a/assets/architecture.svg b/assets/architecture.svg index d04e3d7f..ab49d82a 100644 --- a/assets/architecture.svg +++ b/assets/architecture.svg @@ -44,7 +44,7 @@ - + diff --git a/audits/2023-09-Code4rena.md b/audits/2023-09-Code4rena.md new file mode 100644 index 00000000..9e7dabe6 --- /dev/null +++ b/audits/2023-09-Code4rena.md @@ -0,0 +1,1630 @@ +# Overview + +## About C4 + +Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts. + +A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects. + +During the audit outlined in this document, C4 conducted an analysis of the Centrifuge smart contract system written in Solidity. The audit took place between September 8—September 14 2023. + +## Wardens + +85 Wardens contributed reports to the Centrifuge: + + 1. [ciphermarco](https://code4rena.com/@ciphermarco) + 2. [alexfilippov314](https://code4rena.com/@alexfilippov314) + 3. [0x3b](https://code4rena.com/@0x3b) + 4. [jaraxxus](https://code4rena.com/@jaraxxus) + 5. [twicek](https://code4rena.com/@twicek) + 6. [J4X](https://code4rena.com/@J4X) + 7. [0xStalin](https://code4rena.com/@0xStalin) + 8. [Aymen0909](https://code4rena.com/@Aymen0909) + 9. [bin2chen](https://code4rena.com/@bin2chen) + 10. [imtybik](https://code4rena.com/@imtybik) + 11. [castle\_chain](https://code4rena.com/@castle_chain) + 12. [HChang26](https://code4rena.com/@HChang26) + 13. [merlin](https://code4rena.com/@merlin) + 14. [maanas](https://code4rena.com/@maanas) + 15. [nobody2018](https://code4rena.com/@nobody2018) + 16. [0xnev](https://code4rena.com/@0xnev) + 17. [mert\_eren](https://code4rena.com/@mert_eren) + 18. [Bauchibred](https://code4rena.com/@Bauchibred) + 19. [josephdara](https://code4rena.com/@josephdara) + 20. BARW ([BenRai](https://code4rena.com/@BenRai) and [albertwh1te](https://code4rena.com/@albertwh1te)) + 21. [ast3ros](https://code4rena.com/@ast3ros) + 22. [Ch\_301](https://code4rena.com/@Ch_301) + 23. [degensec](https://code4rena.com/@degensec) + 24. [0xpiken](https://code4rena.com/@0xpiken) + 25. [ravikiranweb3](https://code4rena.com/@ravikiranweb3) + 26. [lsaudit](https://code4rena.com/@lsaudit) + 27. [catellatech](https://code4rena.com/@catellatech) + 28. [Sathish9098](https://code4rena.com/@Sathish9098) + 29. [cats](https://code4rena.com/@cats) + 30. [kaveyjoe](https://code4rena.com/@kaveyjoe) + 31. [0xbrett8571](https://code4rena.com/@0xbrett8571) + 32. [0xfuje](https://code4rena.com/@0xfuje) + 33. [rvierdiiev](https://code4rena.com/@rvierdiiev) + 34. [0xmystery](https://code4rena.com/@0xmystery) + 35. [Kow](https://code4rena.com/@Kow) + 36. [0xRobsol](https://code4rena.com/@0xRobsol) + 37. [codegpt](https://code4rena.com/@codegpt) + 38. [0xkazim](https://code4rena.com/@0xkazim) + 39. [nmirchev8](https://code4rena.com/@nmirchev8) + 40. [gumgumzum](https://code4rena.com/@gumgumzum) + 41. [T1MOH](https://code4rena.com/@T1MOH) + 42. [Kral01](https://code4rena.com/@Kral01) + 43. [pipidu83](https://code4rena.com/@pipidu83) + 44. [Vagner](https://code4rena.com/@Vagner) + 45. [KrisApostolov](https://code4rena.com/@KrisApostolov) + 46. [0xTiwa](https://code4rena.com/@0xTiwa) + 47. [0xgrbr](https://code4rena.com/@0xgrbr) + 48. [PENGUN](https://code4rena.com/@PENGUN) + 49. [bitsurfer](https://code4rena.com/@bitsurfer) + 50. [ether\_sky](https://code4rena.com/@ether_sky) + 51. [0xklh](https://code4rena.com/@0xklh) + 52. [Phantasmagoria](https://code4rena.com/@Phantasmagoria) + 53. [ladboy233](https://code4rena.com/@ladboy233) + 54. [deth](https://code4rena.com/@deth) + 55. [IceBear](https://code4rena.com/@IceBear) + 56. [0xPacelli](https://code4rena.com/@0xPacelli) + 57. [MaslarovK](https://code4rena.com/@MaslarovK) + 58. [grearlake](https://code4rena.com/@grearlake) + 59. [rokinot](https://code4rena.com/@rokinot) + 60. [fouzantanveer](https://code4rena.com/@fouzantanveer) + 61. [K42](https://code4rena.com/@K42) + 62. [hals](https://code4rena.com/@hals) + 63. [emerald7017](https://code4rena.com/@emerald7017) + 64. [foxb868](https://code4rena.com/@foxb868) + 65. [0xLook](https://code4rena.com/@0xLook) + 66. [MohammedRizwan](https://code4rena.com/@MohammedRizwan) + 67. [jkoppel](https://code4rena.com/@jkoppel) + 68. [jolah1](https://code4rena.com/@jolah1) + 69. [alexzoid](https://code4rena.com/@alexzoid) + 70. [sandy](https://code4rena.com/@sandy) + 71. [btk](https://code4rena.com/@btk) + 72. [Kaysoft](https://code4rena.com/@Kaysoft) + 73. [klau5](https://code4rena.com/@klau5) + 74. [0xAadi](https://code4rena.com/@0xAadi) + 75. [Bughunter101](https://code4rena.com/@Bughunter101) + 76. [0xblackskull](https://code4rena.com/@0xblackskull) + 77. [SanketKogekar](https://code4rena.com/@SanketKogekar) + 78. [m\_Rassska](https://code4rena.com/@m_Rassska) + 79. [7ashraf](https://code4rena.com/@7ashraf) + 80. [JP\_Courses](https://code4rena.com/@JP_Courses) + 81. [Krace](https://code4rena.com/@Krace) + 82. [mrudenko](https://code4rena.com/@mrudenko) + 83. [fatherOfBlocks](https://code4rena.com/@fatherOfBlocks) + 84. [0xHelium](https://code4rena.com/@0xHelium) + +This audit was judged by [gzeon](https://code4rena.com/@gzeon). + +Final report assembled by [liveactionllama](https://twitter.com/liveactionllama). + +# Summary + +The C4 analysis yielded an aggregated total of 8 unique vulnerabilities. Of these vulnerabilities, 0 received a risk rating in the category of HIGH severity and 8 received a risk rating in the category of MEDIUM severity. + +Additionally, C4 analysis included 39 reports detailing issues with a risk rating of LOW severity or non-critical. + +All of the issues presented here are linked back to their original finding. + +# Scope + +The code under review can be found within the [C4 Centrifuge audit repository](https://github.com/code-423n4/2023-09-centrifuge), and is composed of 18 smart contracts written in the Solidity programming language and includes 2,657 lines of Solidity code. + +In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, **IllIllI-bot** from warden IllIllI, generated the [Automated Findings report](https://github.com/code-423n4/2023-09-centrifuge/blob/main/bot-report.md) and all findings therein were classified as out of scope. + +# Severity Criteria + +C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical. + +High-level considerations for vulnerabilities span the following key areas when conducting assessments: + +- Malicious Input Handling +- Escalation of privileges +- Arithmetic +- Gas use + +For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on [the C4 website](https://code4rena.com), specifically our section on [Severity Categorization](https://docs.code4rena.com/awarding/judging-criteria/severity-categorization). + +# Medium Risk Findings (8) +## [[M-01] `onlyCentrifugeChainOrigin()` can't require `msg.sender` equal `axelarGateway`](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537) +*Submitted by [bin2chen](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537), also found by [maanas](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/549), [nobody2018](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/442), [castle\_chain](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/290), [mert\_eren](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/289), and [merlin](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/212)* + +In `AxelarRouter.sol`, we need to ensure the legitimacy of the `execute()` method execution, mainly through two methods: + +1. `axelarGateway.validateContractCall ()` to validate if the `command` is approved or not. +2. `onlyCentrifugeChainOrigin()` is used to validate that `sourceChain` `sourceAddress` is legal. + +Let's look at the implementation of `onlyCentrifugeChainOrigin()`: + +```solidity + modifier onlyCentrifugeChainOrigin(string calldata sourceChain, string calldata sourceAddress) { +@> require(msg.sender == address(axelarGateway), "AxelarRouter/invalid-origin"); + require( + keccak256(bytes(axelarCentrifugeChainId)) == keccak256(bytes(sourceChain)), + "AxelarRouter/invalid-source-chain" + ); + require( + keccak256(bytes(axelarCentrifugeChainAddress)) == keccak256(bytes(sourceAddress)), + "AxelarRouter/invalid-source-address" + ); + _; + } +``` + +The problem is that this restriction `msg.sender == address(axelarGateway)`. + +When we look at the official `axelarGateway.sol` contract, it doesn't provide any call external contract 's`execute()` method. + +So `msg.sender` cannot be `axelarGateway`, and the official example does not restrict `msg.sender`. + +The security of the command can be guaranteed by `axelarGateway.validateContractCall()`, `sourceChain`, `sourceAddress`. + +There is no need to restrict `msg.sender`. + +`axelarGateway` code address
+ + +Can't find anything that calls `router.execute()`. + +### Impact + +`router.execute()` cannot be executed properly, resulting in commands from other chains not being executed, protocol not working properly. + +### Recommended Mitigation + +Remove `msg.sender` restriction + +```diff + modifier onlyCentrifugeChainOrigin(string calldata sourceChain, string calldata sourceAddress) { +- require(msg.sender == address(axelarGateway), "AxelarRouter/invalid-origin"); + require( + keccak256(bytes(axelarCentrifugeChainId)) == keccak256(bytes(sourceChain)), + "AxelarRouter/invalid-source-chain" + ); + require( + keccak256(bytes(axelarCentrifugeChainAddress)) == keccak256(bytes(sourceAddress)), + "AxelarRouter/invalid-source-address" + ); + _; + } +``` + +### Assessed type + +Context + +**[hieronx (Centrifuge) confirmed](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537#issuecomment-1723464758)** + +**[gzeon (judge) increased severity to High and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537#issuecomment-1733894416):** + > This seems High risk to me since the Axelar bridge is a centerpiece of this protocol, and when deployed in a certain way where the AxelarRouter is the only ward, it might cause user deposits to be stuck forever. + +**[gzeon (judge) decreased severity to Medium and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537#issuecomment-1735848904):** + > Reconsidering severity to Medium here since the expected setup would have DelayedAdmin able to unstuck the system. + +**[hieronx (Centrifuge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/537#issuecomment-1745247688):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/168 + + + +*** + +## [[M-02] `LiquidityPool::requestRedeemWithPermit` transaction can be front run with the different liquidity pool](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/227) +*Submitted by [alexfilippov314](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/227)* + +The permit signature is linked only to the tranche token. That's why it can be used with any liquidity pool with the same tranche token. Since anyone can call `LiquidityPool::requestRedeemWithPermit` the following scenario is possible: + +1. Let's assume that some user has some amount of tranche tokens. Let's also assume that there are multiple liquidity pools with the same tranche token. For example, USDX pool and USDY pool. +2. The user wants to redeem USDX from the USDX pool using `requestRedeemWithPermit`. The user signs the permit and sends a transaction. +3. A malicious actor can see this transaction in the mempool and use the signature from it to request a redemption from the USDY pool with a greater fee amount. +4. Since this transaction has a greater fee amount it will likely be executed before the valid transaction. +5. The user's transaction will be reverted since the permit has already been used. +6. If the user will not cancel this malicious request until the end of the epoch this request will be executed, and the user will be forced to claim USDY instead of USDX. + +This scenario assumes some user's negligence and usually doesn't lead to a significant loss. But in some cases (for example, USDY depeg) a user can end up losing significantly. + +### Proof of Concept + +The test below illustrates the scenario described above: + +```solidity +function testPOCIssue1( + uint64 poolId, + string memory tokenName, + string memory tokenSymbol, + bytes16 trancheId, + uint128 currencyId, + uint256 amount +) public { + vm.assume(currencyId > 0); + vm.assume(amount < MAX_UINT128); + vm.assume(amount > 1); + + // Use a wallet with a known private key so we can sign the permit message + address investor = vm.addr(0xABCD); + vm.prank(vm.addr(0xABCD)); + + LiquidityPool lPool = + LiquidityPool(deployLiquidityPool(poolId, erc20.decimals(), tokenName, tokenSymbol, trancheId, currencyId)); + erc20.mint(investor, amount); + homePools.updateMember(poolId, trancheId, investor, type(uint64).max); + + // Sign permit for depositing investment currency + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + 0xABCD, + keccak256( + abi.encodePacked( + "\x19\x01", + erc20.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + erc20.PERMIT_TYPEHASH(), investor, address(investmentManager), amount, 0, block.timestamp + ) + ) + ) + ) + ); + + lPool.requestDepositWithPermit(amount, investor, block.timestamp, v, r, s); + // To avoid stack too deep errors + delete v; + delete r; + delete s; + + // ensure funds are locked in escrow + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(investor), 0); + + // collect 50% of the tranche tokens + homePools.isExecutedCollectInvest( + poolId, + trancheId, + bytes32(bytes20(investor)), + poolManager.currencyAddressToId(address(erc20)), + uint128(amount), + uint128(amount) + ); + + uint256 maxMint = lPool.maxMint(investor); + lPool.mint(maxMint, investor); + + { + TrancheToken trancheToken = TrancheToken(address(lPool.share())); + assertEq(trancheToken.balanceOf(address(investor)), maxMint); + + // Sign permit for redeeming tranche tokens + (v, r, s) = vm.sign( + 0xABCD, + keccak256( + abi.encodePacked( + "\x19\x01", + trancheToken.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + trancheToken.PERMIT_TYPEHASH(), + investor, + address(investmentManager), + maxMint, + 0, + block.timestamp + ) + ) + ) + ) + ); + } + + // Let's assume that there is another liquidity pool with the same poolId and trancheId + // but a different currency + LiquidityPool newLPool; + { + assert(currencyId != 123); + address newErc20 = address(_newErc20("Y's Dollar", "USDY", 6)); + homePools.addCurrency(123, newErc20); + homePools.allowPoolCurrency(poolId, 123); + newLPool = LiquidityPool(poolManager.deployLiquidityPool(poolId, trancheId, newErc20)); + } + assert(address(lPool) != address(newLPool)); + + // Malicious actor can use the signature extracted from the mempool to + // request redemption from the different liquidity pool + vm.prank(makeAddr("malicious")); + newLPool.requestRedeemWithPermit(maxMint, investor, block.timestamp, v, r, s); + + // User's transaction will fail since the signature has already been used + vm.expectRevert(); + lPool.requestRedeemWithPermit(maxMint, investor, block.timestamp, v, r, s); +} +``` + +### Recommended Mitigation Steps + +One of the ways to mitigate this issue is to add some identifier of the liquidity pool to the permit message. This way permit will be linked to a specific liquidity pool. + +**[hieronx (Centrifuge) confirmed and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/227#issuecomment-1745023712):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/159 + + + +*** + +## [[M-03] Cached `DOMAIN_SEPARATOR` is incorrect for tranche tokens potentially breaking permit integrations](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/146) +*Submitted by [Kow](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/146), also found by [0xRobsol](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/746), [codegpt](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/730), [0xkazim](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/710), [josephdara](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/697), [Aymen0909](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/586), 0xpiken ([1](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/539), [2](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/345)), [bin2chen](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/536), [nmirchev8](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/426), rvierdiiev ([1](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/404), [2](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/402)), lsaudit ([1](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/313), [2](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/306)), [0xfuje](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/247), [gumgumzum](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/214), [T1MOH](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/102), and ravikiranweb3 ([1](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/65), [2](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/52), [3](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/51))* + +Attempts to interact with tranche tokens via `permit` may always revert. + +### Proof of Concept + +When new tranche tokens are deployed, the initial `DOMAIN_SEPARATOR` is calculated and cached in the constructor.
+ + +```solidity + constructor(uint8 decimals_) { + ... + deploymentChainId = block.chainid; + _DOMAIN_SEPARATOR = _calculateDomainSeparator(block.chainid); + } +``` + +This uses an empty string since `name` is only set after deployment.
+ + +```solidity + function newTrancheToken( + uint64 poolId, + bytes16 trancheId, + string memory name, + string memory symbol, + uint8 decimals, + address[] calldata trancheTokenWards, + address[] calldata restrictionManagerWards + ) public auth returns (address) { + ... + TrancheToken token = new TrancheToken{salt: salt}(decimals); + + token.file("name", name); + ... + } +``` + +Consequently, the domain separator is incorrect (when `block.chainid == deploymentChainId` where the domain separator is not recalculated) and will cause reverts when signatures for `permit` are attempted to be constructed using the tranche token's `name` (which will not be empty). + +It should also be noted that the tranche token `name` could be changed by a call to `updateTranchTokenMetadata` which may also introduce complications with the domain separator. + +### Recommended Mitigation Steps + +Consider setting the name in the constructor before the cached domain separator is calculated. + +**[hieronx (Centrifuge) confirmed and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/146#issuecomment-1745025208):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/142 + + + +*** + +## [[M-04] You can deposit really small amount for other users to DoS them](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/143) +*Submitted by [0x3b](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/143), also found by [jaraxxus](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/686) and [twicek](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/283)* + +Deposit and mint under [**LiquidityPool**](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L152) lack access control, which enables any user to **proceed** the mint/deposit for another user. Attacker can deposit (this does not require tokens) some wai before users TX to DoS the deposit. + +### Proof of Concept + +[deposit](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) and [mint](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L148-L152) do [processDeposit](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L427-L441)/[processMint](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L451-L465) which are the secondary functions to the requests. These function do not take any value in the form of tokens, but only send shares to the receivers. This means they can be called for free. + +With this an attacker who wants to DoS a user, can wait him to make the request to deposit and on the next epoch front run him by calling [deposit](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) with something small like 1 wei. Afterwards when the user calls `deposit`, his TX will inevitable revert, as he will not have enough balance for the full deposit. + +### Recommended Mitigation Steps + +Have some access control modifiers like [**withApproval**](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L97-L100) used also in [redeem](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L200-L208). + +```diff +- function deposit(uint256 assets, address receiver) public returns (uint256 shares) { ++ function deposit(uint256 assets, address receiver) public returns (uint256 shares) withApproval(receiver) { + shares = investmentManager.processDeposit(receiver, assets); + emit Deposit(address(this), receiver, assets, shares); + } + +- function mint(uint256 shares, address receiver) public returns (uint256 assets) { ++ function mint(uint256 shares, address receiver) public returns (uint256 assets) withApproval(receiver) { + assets = investmentManager.processMint(receiver, shares); + emit Deposit(address(this), receiver, assets, shares); + } +``` + +### Assessed type + +Access Control + +**[hieronx (Centrifuge) confirmed and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/143#issuecomment-1745028559):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/136 + + + +*** + +## [[M-05] Investors claiming their `maxDeposit` by using the `LiquidityPool.deposit()` will cause other users to be unable to claim their `maxDeposit`/`maxMint`](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118) +*Submitted by [0xStalin](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118), also found by [Aymen0909](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/647), [imtybik](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/532), [HChang26](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/321), and [J4X](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/210)* + +Claiming deposits using the [`LiquidityPool.deposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) will cause the Escrow contract to not have enough shares to allow other investors to claim their maxDeposit or maxMint values for their deposited assets. + +### Proof of Concept + +* Before an investor can claim their deposits, they first needs to request the deposit and wait for the Centrigue Chain to validate it in the next epoch. + +* Investors can request deposits at different epochs without the need to claim all the approved deposits before requesting a new deposit, in the end, the maxDeposit and maxMint values that the investor can claim will be increased accordingly based on all the request deposits that the investor makes. + +* When the requestDeposit of the investor is processed in the Centrifuge chain, a number of TrancheShares will be minted based on the price at the moment when the request was processed and the total amount of deposited assets, this TrancheShares will be deposited to the Escrow contract, and the TrancheShares will be waiting for the investors to claim their deposits. + +* When investors decide to claim their deposit they can use the [`LiquidityPool.deposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) function, this function receives as arguments the number of assets that are being claimed and the address of the account to claim the deposits for. + +```solidity +function deposit(uint256 assets, address receiver) public returns (uint256 shares) { + shares = investmentManager.processDeposit(receiver, assets); + emit Deposit(address(this), receiver, assets, shares); +} +``` + +* The [`LiquidityPool.deposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) function calls the [`InvestmentManager::processDeposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L427-L441) which will validate that the amount of assets being claimed doesn't exceed the investor's deposit limits, will compute the deposit price in the [`InvestmentManager::calculateDepositPrice()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L551-L558), which basically computes an average price for all the request deposits that have been accepted in the Centrifuge Chain, each of those request deposits could've been executed at a different price, so, this function, based on the values of maxDeposit and maxMint will estimate an average price for all the unclaimed deposits, later, using this computed price for the deposits will compute the equivalent of TrancheTokens for the CurrencyAmount being claimed, and finally, processDeposit() will transferFrom the escrow to the investor account the computed amount of TranchTokens. + +```solidity +function processDeposit(address user, uint256 currencyAmount) public auth returns (uint256 trancheTokenAmount) { + address liquidityPool = msg.sender; + uint128 _currencyAmount = _toUint128(currencyAmount); + require( + //@audit-info => orderbook[][].maxDeposit is updated when the handleExecutedCollectInvest() was executed! + //@audit-info => The orderbook keeps track of the number of TrancheToken shares that have been minted to the Escrow contract on the user's behalf! + (_currencyAmount <= orderbook[user][liquidityPool].maxDeposit && _currencyAmount != 0), + "InvestmentManager/amount-exceeds-deposit-limits" + ); + + //@audit-info => computes an average price for all the request deposits that have been accepted in the Centrifuge Chain and haven't been claimed yet! + uint256 depositPrice = calculateDepositPrice(user, liquidityPool); + require(depositPrice != 0, "LiquidityPool/deposit-token-price-0"); + + //@audit-info => Based on the computed depositPrice will compute the equivalent of TrancheTokens for the CurrencyAmount being claimed + uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, depositPrice); + + //@audit-info => transferFrom the escrow to the investor account the computed amount of TranchTokens. + _deposit(_trancheTokenAmount, _currencyAmount, liquidityPool, user); + trancheTokenAmount = uint256(_trancheTokenAmount); +} +``` + +**The problem** occurs when an investor hasn't claimed their deposits and has requested multiple deposits on different epochs at different prices. The [`InvestmentManager::calculateDepositPrice()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L551-L558) function will compute an equivalent/average price for all the requestDeposits that haven't been claimed yet. Because of the different prices that the request deposits where processed at, the computed price will compute the most accurate average of the deposit's price, but there is a slight rounding error that causes the computed value of trancheTokenAmount to be slightly different from what it should exactly be. + +* That slight difference will make that the Escrow contract transfers slightly more shares to the investor claiming the deposits by using the [`LiquidityPool.deposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) +* **As a result**, when another investor tries to claim their [maxDeposit](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L129-L132) or [maxMint](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L154-L157), now the Escrow contract doesn't have enough shares to make whole the request of the other investor, and as a consequence the other investor transaction will be reverted. That means the second investor won't be able to claim all the shares that it is entitled to claim because the Escrow contract doesn't have all those shares anymore. + +**Coded PoC** + +* I used the [`LiquidityPool.t.sol`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/test/LiquidityPool.t.sol) test file as the base file for this PoC, please add the below testPoC to the LiquidityPool.t.sol file + +* In this PoC I demonstrate that Alice (A second investor) won't be able to claim her maxDeposit or maxMint amounts after the first investor uses the [`LiquidityPool.deposit()`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L141-L144) function to claim his [maxDeposit() assets](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/LiquidityPool.sol#L129-L132). The first investor makes two requestDeposit, each of them at a different epoch and at a different price, Alice on the other hand only does 1 requestDeposit in the second epoch. + +* Run this PoC two times, check the comments on the last 4 lines, one time we want to test Alice claiming her deposits using LiquidityPool::deposit(), and the second time using LiquidityPool::mint() + * The two executions should fail with the same problem. + +
+ +```solidity + function testDepositAtDifferentPricesPoC(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + homePools.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000000000000000); + + //@audit-info => Add Alice as a Member + address alice = address(0x23232323); + homePools.updateMember(poolId, trancheId, alice, type(uint64).max); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + homePools.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(address(investmentManager), investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a price of 1.25 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 100000000; // 100 * 10**6 + uint128 firstTrancheTokenPayout = 80000000000000000000; // 100 * 10**18 / 1.25, rounded down + homePools.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, firstTrancheTokenPayout + ); + + // assert deposit & mint values adjusted + assertEq(lPool.maxDeposit(self), currencyPayout); + assertEq(lPool.maxMint(self), firstTrancheTokenPayout); + + // deposit price should be ~1.25*10**18 === 1250000000000000000 + assertEq(investmentManager.calculateDepositPrice(self, address(lPool)), 1250000000000000000); + + + // second investment in a different epoch => different price + currency.approve(address(investmentManager), investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a price of 2 + currencyPayout = 100000000; // 100 * 10**6 + uint128 secondTrancheTokenPayout = 50000000000000000000; // 100 * 10**18 / 1.4, rounded down + homePools.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout + ); + + // Alice invests the same amount as the other investor in the second epoch - Price is at 2 + currency.mint(alice, investmentAmount); + + vm.startPrank(alice); + currency.approve(address(investmentManager), investmentAmount); + lPool.requestDeposit(investmentAmount, alice); + vm.stopPrank(); + + homePools.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(alice)), _currencyId, currencyPayout, secondTrancheTokenPayout + ); + + uint128 AliceTrancheTokenPayout = 50000000000000000000; // 100 * 10**18 / 1.4, rounded down + + //@audit-info => At this point, the Escrow contract should have the firstTrancheTokenPayout + secondTrancheTokenPayout + AliceTrancheTokenPayout + assertEq(lPool.balanceOf(address(escrow)),firstTrancheTokenPayout + secondTrancheTokenPayout + AliceTrancheTokenPayout); + + + // Investor collects his the deposited assets using the LiquidityPool::deposit() + lPool.deposit(lPool.maxDeposit(self), self); + + + // Alice tries to collect her deposited assets and gets her transactions reverted because the Escrow doesn't have the required TokenShares for Alice! + vm.startPrank(alice); + + //@audit-info => Run the PoC one time to test Alice trying to claim their deposit using LiquidityPool.deposit() + lPool.deposit(lPool.maxDeposit(alice), alice); + + //@audit-info => Run the PoC a second time, but now using LiquidityPool.mint() + // lPool.mint(lPool.maxMint(alice), alice); + vm.stopPrank(); + } +``` + +
+ +### Recommended Mitigation Steps + +* I'd recommend to add a check to the computed value of the [`_trancheTokenAmount`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L438) in the `InvestmentManager::processDeposit()`, if the `_trancheTokenAmount` exceeds the `maxMint()` of the user, update it and set it to be the maxMint(), in this way, the rounding differences will be discarded before doing the actual transfer of shares from the Escrow to the user, and this will prevent the Escrow from not having all the required TranchToken for the other investors + +```solidity +function processDeposit(address user, uint256 currencyAmount) public auth returns (uint256 trancheTokenAmount) { + address liquidityPool = msg.sender; + uint128 _currencyAmount = _toUint128(currencyAmount); + require( + (_currencyAmount <= orderbook[user][liquidityPool].maxDeposit && _currencyAmount != 0), + "InvestmentManager/amount-exceeds-deposit-limits" + ); + + uint256 depositPrice = calculateDepositPrice(user, liquidityPool); + require(depositPrice != 0, "LiquidityPool/deposit-token-price-0"); + + uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, depositPrice); + + //@audit => Add this check to prevent any rounding errors from causing problems when transfering shares from the Escrow to the Investor! ++ if (_trancheTokenAmount > orderbook[user][liquidityPool].maxMint) _trancheTokenAmount = orderbook[user][liquidityPool].maxMint; + + _deposit(_trancheTokenAmount, _currencyAmount, liquidityPool, user); + trancheTokenAmount = uint256(_trancheTokenAmount); +} +``` + +* After applying the suggested recommendation, you can use the provided PoC on this report to verify that the problem has been solved. + +### Assessed type + +Math + +**[hieronx (Centrifuge) confirmed](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118#issuecomment-1728512469)** + +**[hieronx (Centrifuge) commented via duplicate issue `#210`](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/210#issuecomment-1728512207):** + > I believe issues [210](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/210), [34](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/34) and [118](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118) all come down to the same underlying issues, and I will try to respond with our current understanding (although we are still digging further). +> +> There are actually multiple rounding issues coming together in the system right now: +> 1. If there are multiple executions of an order, there can be loss of precision when these values are added to each other. +> 2. If there are multiple `deposit` or `mint` calls, there can be loss of precision in the amount of tranche tokens they receive, based on the computed deposit price. +> 3. If there are multiple `deposit` or `mint` calls, there can be loss of precision in the implied new price through the subtracted `maxDeposit` / `maxMint` values. +> +> We've written [a new fuzz test](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/210#issuecomment-1728512207), building on the work from the reported findings, that clearly shows the underlying issue. +> +> Unfortunately it also makes clear that this issue cannot be solved by the recommended mitigation steps from the 3 different findings on their own, and it requires deeper changes. We are still investigating this. +> +> Now, in terms of the severity, it does not directly lead to a loss of funds. As noted in [issue 210](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/210), the `UserEscrow` logic prevents users from withdrawing more currency/funds than they are entitled to. However, it does lead to users being able to receive more tranche tokens (shares). I will leave it to the judges to make a decision on the severity for this. +> +> Thanks to the 3 wardens for reporting these issues! + +**[gzeon (judge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118#issuecomment-1733725182):** + > This issue described a protocol specific issue with multiple deposit/withdrawal, where [issue 34](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/34) is a generic 4626 rounding issue, and therefore not marked as duplicate of issue 34. In terms of severity, this does not directly lead to a loss of fund but will affect the availability of the protocol, hence Medium. + +**[hieronx (Centrifuge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/118#issuecomment-1745029786):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/166 + + + +*** + +## [[M-06] DelayedAdmin Cannot `PauseAdmin.removePauser`](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/92) +*Submitted by [ciphermarco](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/92)* + +As per the audit repository's documentation, which is confirmed as up-to-date, there are carefully considered emergency scenarios. Among these scenarios, one is described as follows: + +: + + **Someone controls 1 pause admin and triggers a malicious `pause()`** + + * The delayed admin is a `ward` on the pause admin and can trigger `PauseAdmin.removePauser`. + * It can then trigger `root.unpause()`. + +That makes perfect sense from a security perspective. However the provided `DelayedAdmin` implementation lacks the necessary functionality to execute `PauseAdmin.removePauser` in the case of an emergency. + +Striving to adhere to the documented [Severity Categorization](https://docs.code4rena.com/awarding/judging-criteria/severity-categorization), I have categorized this as Medium instead of Low. The reason is that it does not qualify as Low due to representing both a "function incorrect as to spec" issue and a critical feature missing from the project's security model. Without this emergency action for `PauseAdmin`, other recovery paths may have to wait for `Root`'s delay period or, at least temporarily, change the protocol's security model to make a recovery. In my view, this aligns with the "Assets not at direct risk, but the function of the protocol or its availability could be impacted" requirement for Medium severity. With that said, I realize the sponsors and judges will ultimately evaluate and categorise it based on their final risk analysis, not mine. I'm simply streamlining the process by presenting my perspective in advance. + +### Proof of Concept + +In order to remove a pauser from the `PauseAdmin` contract, the `removePause` function must be called: + +: + +``` + function removePauser(address user) external auth { + pausers[user] = 0; + emit RemovePauser(user); + } +``` + +Since it is a short contract, here is the whole `DelayedAdmin` implementation: + + + +``` +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import {Root} from "../Root.sol"; +import {Auth} from "./../util/Auth.sol"; + +/// @title Delayed Admin +/// @dev Any ward on this contract can trigger +/// instantaneous pausing and unpausing +/// on the Root, as well as schedule and cancel +/// new relys through the timelock. +contract DelayedAdmin is Auth { + Root public immutable root; + + // --- Events --- + event File(bytes32 indexed what, address indexed data); + + constructor(address root_) { + root = Root(root_); + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + // --- Admin actions --- + function pause() public auth { + root.pause(); + } + + function unpause() public auth { + root.unpause(); + } + + function scheduleRely(address target) public auth { + root.scheduleRely(target); + } + + function cancelRely(address target) public auth { + root.cancelRely(target); + } +} +``` + +No implemented functionalities exist to trigger `PauseAdmin.removePauser`. Additionally, the contract features an unused `event File`, +a and fails to record the `PauseAdmin`'s address upon initiation or elsewhere. + +As anticipated, `Auth` (inherited) also does not handle this responsibility: + +To confirm that I did not misunderstand anything, I thoroughly searched the entire audit repository for occurrences of `removePauser`. +However, I could only find them in `PauseAdmin.sol`, where the function to remove a pauser is implemented, and in a test case that +directly calls the `PauseAdmin`. + +### Recommended Mitigation Steps + +**1. Implement the `PauseAdmin.removePauser` Functionality in `DelayedAdmin.sol` with This Diff:** + +```diff +5a6 +> import {PauseAdmin} from "./PauseAdmin.sol"; +13a15 +> PauseAdmin public immutable pauseAdmin; +18c20 +< constructor(address root_) { +--- +> constructor(address root_, address pauseAdmin_) { +19a22 +> pauseAdmin = PauseAdmin(pauseAdmin_); +41,42c44,48 +< // @audit HM? How can delayed admin call `PauseAdmin.removePauser if not coded here? +< // @audit According to documentation: "The delayed admin is a ward on the pause admin and can trigger PauseAdmin.removePauser." +--- +> +> // --- Emergency actions -- +> function removePauser(address pauser) public auth { +> pauseAdmin.removePauser(pauser); +> } + +``` + +**2. Add This Test Function to `AdminTest` Contract in `test/Admin.t.sol`** + +``` + function testEmergencyRemovePauser() public { + address evilPauser = address(0x1337); + pauseAdmin.addPauser(evilPauser); + assertEq(pauseAdmin.pausers(evilPauser), 1); + + delayedAdmin.removePauser(evilPauser); + assertEq(pauseAdmin.pausers(evilPauser), 0); + } +``` + +**3. Change the `DelayedAdmin` Creation in `script/Deployer.sol` with This diff:** + +```diff +55c55 +< delayedAdmin = new DelayedAdmin(address(root)); +--- +> delayedAdmin = new DelayedAdmin(address(root), address(pauseAdmin)); + +``` + +**4. Test** + +`$ forge test` + +**[RaymondFam (lookout) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/92#issuecomment-1720289509):** + > The sponsor has highlighted in Discord that these are EOAs capable of triggering to removePauser. + +**[gzeon (judge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/92#issuecomment-1735729992):** + > Since the `removePauser` usecase is explicitly documented in the README and DelayedAdmin.sol is in-scope, I believe this is a valid Medium issue as the warden described. + +**[hieronx (Centrifuge) confirmed and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/92#issuecomment-1745030969):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/139 + + + +*** + +## [[M-07] ```trancheTokenAmount``` should be rounded UP when proceeding to a withdrawal or previewing a withdrawal](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/34) +*Submitted by [pipidu83](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/34), also found by [Vagner](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/798), [KrisApostolov](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/774), [0xTiwa](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/680), [josephdara](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/664), [0xgrbr](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/644), [PENGUN](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/562), [Kral01](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/542), [bitsurfer](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/535), [ether\_sky](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/470), [0xklh](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/444), [merlin](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/421), [maanas](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/367), [Phantasmagoria](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/349), [lsaudit](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/318), [castle\_chain](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/287), [ladboy233](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/281), [deth](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/106), [IceBear](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/105), [0xPacelli](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/82), and [MaslarovK](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/76)* + +This is good practice when implementing the EIP-4626 vault standard as it is more secure to favour the vault than its users in that case.
+This can also lead to issues down the line for other protocol integrating Centrifuge, that may assume that rounding was handled according to EIP-4626 best practices. + +### Proof of Concept + +When calling the [`processWithdraw`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L515) function, the `trancheTokenAmount` is computed through the [`_calculateTrancheTokenAmount`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L591) function, which rounds DOWN the number of shares required to be burnt to receive the `currencyAmount` payout/withdrawal. + +```solidity +/// @dev Processes user's tranche token redemption after the epoch has been executed on Centrifuge. +/// In case user's redempion order was fullfilled on Centrifuge during epoch execution MaxRedeem and MaxWithdraw +/// are increased and LiquidityPool currency can be transferred to user's wallet on calling processRedeem or processWithdraw. +/// Note: The trancheTokenAmount required to fullfill the redemption order was already locked in escrow upon calling requestRedeem and burned upon collectRedeem. +/// @notice trancheTokenAmount return value is type of uint256 to be compliant with EIP4626 LiquidityPool interface +/// @return trancheTokenAmount the amount of trancheTokens redeemed/burned required to receive the currencyAmount payout/withdrawel. +function processWithdraw(uint256 currencyAmount, address receiver, address user) +public +auth +returns (uint256 trancheTokenAmount) +{ +address liquidityPool = msg.sender; +uint128 _currencyAmount = _toUint128(currencyAmount); +require( +(_currencyAmount <= orderbook[user][liquidityPool].maxWithdraw && _currencyAmount != 0), +"InvestmentManager/amount-exceeds-withdraw-limits" +); + +uint256 redeemPrice = calculateRedeemPrice(user, liquidityPool); +require(redeemPrice != 0, "LiquidityPool/redeem-token-price-0"); + +uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, redeemPrice); +_redeem(_trancheTokenAmount, _currencyAmount, liquidityPool, receiver, user); +trancheTokenAmount = uint256(_trancheTokenAmount); +} +``` + +```solidity +function _calculateTrancheTokenAmount(uint128 currencyAmount, address liquidityPool, uint256 price) +internal +view +returns (uint128 trancheTokenAmount) +{ +(uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); + +uint256 currencyAmountInPriceDecimals = _toPriceDecimals(currencyAmount, currencyDecimals, liquidityPool).mulDiv( +10 ** PRICE_DECIMALS, price, MathLib.Rounding.Down +); + +trancheTokenAmount = _fromPriceDecimals(currencyAmountInPriceDecimals, trancheTokenDecimals, liquidityPool); +} +``` + +As an additional reason the round UP the amount, the computed amount of shares is also used to [`_decreaseRedemptionLimits`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L635C14-L635C39), which could potentially lead to a rounded UP remaining redemption limit post withdrawal (note that for the same reason it would we wise to round UP the `_currency` amount as well when calling `_decreaseRedemptionLimits`). + +The same function is used in the [`previewWithdraw`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L396) function, where is should be rounded UP for the same reasons. + +```solidity +/// @return trancheTokenAmount is type of uin256 to support the EIP4626 Liquidity Pool interface +function previewWithdraw(address user, address liquidityPool, uint256 _currencyAmount) +public +view +returns (uint256 trancheTokenAmount) +{ +uint128 currencyAmount = _toUint128(_currencyAmount); +uint256 redeemPrice = calculateRedeemPrice(user, liquidityPool); +if (redeemPrice == 0) return 0; + +trancheTokenAmount = uint256(_calculateTrancheTokenAmount(currencyAmount, liquidityPool, redeemPrice)); +} +``` + +### Tools Used + +Visual Studio / Manual Review + +### Recommended Mitigation Steps + +As the we do not always want to round the amount of shares UP in [`_calculateTrancheTokenAmount`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L591) (e.g. when used in [`previewDeposit`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L370) or [`processDeposit`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L427) the shares amount is correctly rounded DOWN), the function would actually require an extra argument like below: + +```solidity +function _calculateTrancheTokenAmount(uint128 currencyAmount, address liquidityPool, uint256 price, Math.Rounding rounding) +internal +view +returns (uint128 trancheTokenAmount) +{ +(uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); + +uint256 currencyAmountInPriceDecimals = _toPriceDecimals(currencyAmount, currencyDecimals, liquidityPool).mulDiv( +10 ** PRICE_DECIMALS, price, MathLib.Rounding.Down +); + +trancheTokenAmount = _fromPriceDecimals(currencyAmountInPriceDecimals, trancheTokenDecimals, liquidityPool); +} +``` + +And be used as + +```solidity +_calculateTrancheTokenAmount(currencyAmount, liquidityPool, redeemPrice, Math.Rounding.Ceil) +``` + +In [`previewWithdraw`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L396) and [`processWithdraw`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L515) + +And + +```solidity +_calculateTrancheTokenAmount(_currencyAmount, liquidityPool, depositPrice, Math.Rounding.Floor) +``` + +In [`previewDeposit`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L370) and [`processDeposit`](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/InvestmentManager.sol#L427). + +### Assessed type + +Math + +**[hieronx (Centrifuge) confirmed and commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/34#issuecomment-1745030227):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/166 + +*** + +## [[M-08] The Restriction Manager does not completely implement ERC1404 which leads to accounts that are supposed to be restricted actually having access to do with their tokens as they see fit](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779) +*Submitted by [Bauchibred](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779), also found by [josephdara](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/792), [ast3ros](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/625), [Ch\_301](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/413), [BARW](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/388), [0xnev](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/356), [degensec](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/49), and [J4X](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/14)* + +Medium, contract's intended logic is for *blacklisted* users not to be able to interact with their system so as to follow rules set by regulationary bodies in the case where a user does anything that warrants them to be blacklisted, but this is clearly broken since only half the window is closed as current implementation only checks on receiver being blacklisted and not sender. + +### Proof of Concept + +The current implementation of the ERC1404 restrictions within the `RestrictionManager.sol` contract only places restrictions on the receiving address in token transfer instances. This oversight means that the sending addresses are not restricted, which poses a regulatory and compliance risk. Should a user be `blacklisted` for any reason, they can continue to transfer tokens as long as the receiving address is a valid member. This behaviour is contrary to expectations from regulatory bodies, especially say in the U.S where these bodies are very strict and a little in-compliance could land Centrifuge a lawsuit., which may expect complete trading restrictions for such blacklisted individuals. + +Within the `RestrictionManager` contract, the method `detectTransferRestriction` only checks if the receiving address (`to`) is a valid member: + +[RestrictionManager.sol#L28-L34](https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/token/RestrictionManager.sol#L28-L34) + +```solidity +function detectTransferRestriction(address from, address to, uint256 value) public view returns (uint8) { + if (!hasMember(to)) { + return DESTINATION_NOT_A_MEMBER_RESTRICTION_CODE; + } + return SUCCESS_CODE; +} +``` + +In the above code, the sending address (`from`) is never checked against the membership restrictions, which means blacklisted users can still initiate transfers and when checking the transfer restriction from both `tranchtoken.sol` and the `liquiditypool.sol` it's going to wrongly return true for a personnel that should be false + +See [Tranche.sol#L80-L82)](https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/token/Tranche.sol#L80-L82) + +```solidity +// function checkTransferRestriction(address from, address to, uint256 value) public view returns (bool) { +// return share.checkTransferRestriction(from, to, value); +// } +``` + +Also [Tranche.sol#L35-L39](https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/token/Tranche.sol#L35-L39) + +```solidity + modifier restricted(address from, address to, uint256 value) { + uint8 restrictionCode = detectTransferRestriction(from, to, value); + require(restrictionCode == restrictionManager.SUCCESS_CODE(), messageForTransferRestriction(restrictionCode)); + _; + } +``` + +This function suggests that the system's logic may rely on the `detectTransferRestriction` method in other parts of the ecosystem. Consequently, if the restriction manager's logic is flawed, these other parts may also allow unauthorised transfers. + +**Foundry POC** + +Add this to the `Tranche.t.sol` contract + +```solidity + function testTransferFromTokensFromBlacklistedAccountWorks(uint256 amount, address targetUser, uint256 validUntil) public { + vm.assume(baseAssumptions(validUntil, targetUser)); + + restrictionManager.updateMember(targetUser, validUntil); + assertEq(restrictionManager.members(targetUser), validUntil); + restrictionManager.updateMember(address(this), block.timestamp); + assertEq(restrictionManager.members(address(this)), block.timestamp); + + token.mint(address(this), amount); + vm.warp(block.timestamp + 1); + + token.transferFrom(address(this), targetUser, amount); + assertEq(token.balanceOf(targetUser), amount); + } +``` + +As seen even after `address(this)` stops being a member they could still transfer tokens to another user in as much as said user is still a member, which means a *blacklisted* user could easily do anything with their tokens all they need to do is to delegate to another member. + +### Recommended Mitigation Steps + +Refactor`detectTransferRestriction()`, i.e modify the method to also validate the sending address (`from`). Ensure both `from` and `to` addresses are valid members before allowing transfers. + +If this is contract's intended logic then this information should be duly passed to the regulatory bodies that a `user` can't really get blacklisted and more of user's could be stopped from receiving tokens. + +### Assessed type + +Context + +**[hieronx (Centrifuge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779#issuecomment-1734178040):** + > This is a design decision. Transferring to a valid member is fine if that destination is still allowed to hold the tokens. + +**[gzeon (judge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779#issuecomment-1735811683):** + > > This is a design decision. Transferring to a valid member is fine if that destination is still allowed to hold the tokens. +> +> This seems to contradict with `Removing an investor from the memberlist in the Restriction Manager locks their tokens. This is expected behaviour.` (See [here](https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/README.md?plain=1#L140)) + +**[hieronx (Centrifuge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779#issuecomment-1735838137):** + > Yes you're right, that's unfortunate wording. What I said above is correct, and the text in the README is incorrect. What was meant was that it locks their tranche tokens from being redeemed. +> +> It is indeed fair to say though that, based on the README text, the above issue is valid. + +**[hieronx (Centrifuge) commented](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/779#issuecomment-1745021967):** + > Mitigated in https://github.com/centrifuge/liquidity-pools/pull/138 + +*** + +# Low Risk and Non-Critical Issues + +For this audit, 39 reports were submitted by wardens detailing low risk and non-critical issues. The [report highlighted below](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/548) by **castle_chain** received the top score from the judge. + +*The following wardens also submitted reports: [Bauchibred](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/784), [0xpiken](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/468), [BARW](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/427), [0xmystery](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/397), [0xfuje](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/392), [0xLook](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/770), [merlin](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/767), [MohammedRizwan](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/765), [jkoppel](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/761), [jolah1](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/740), [alexzoid](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/663), [sandy](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/653), [btk](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/649), [Kaysoft](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/642), [klau5](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/636), [ast3ros](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/626), [0xAadi](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/606), [Bughunter101](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/597), [0xblackskull](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/585), [0xnev](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/559), [SanketKogekar](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/545), [m\_Rassska](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/509), [grearlake](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/475), [nobody2018](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/471), [7ashraf](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/463), [JP\_Courses](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/460), [Krace](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/420), [Ch\_301](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/410), [lsaudit](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/319), [catellatech](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/314), [imtybik](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/246), [mrudenko](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/213), [Sathish9098](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/207), [rvierdiiev](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/187), [rokinot](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/46), [fatherOfBlocks](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/43), [0xHelium](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/32), and [degensec](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/25).* + +## [L-01] The function `addTranche()` should validate the tranche token decimals to make sure that the decimals are not greater than 18 + +```solidity + function addTranche( + uint64 poolId, + bytes16 trancheId, + string memory tokenName, + string memory tokenSymbol, + uint8 decimals + ) public onlyGateway { + Pool storage pool = pools[poolId]; + require(pool.createdAt != 0, "PoolManager/invalid-pool"); + Tranche storage tranche = pool.tranches[trancheId]; + require(tranche.createdAt == 0, "PoolManager/tranche-already-exists"); + + + tranche.poolId = poolId; + tranche.trancheId = trancheId; + @> tranche.decimals = decimals; + tranche.tokenName = tokenName; + tranche.tokenSymbol = tokenSymbol; + tranche.createdAt = block.timestamp; + + + emit TrancheAdded(poolId, trancheId); + } +``` + +## [[L-02] Unlimiting the delay period with minimum threshold allows the delay period to be set too low and allows a malicious `ScheduleUpgrade` message to be executed on the root contract and gain access on the other contracts](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/298) + +*Note: this finding was originally submitted separately by the warden at a higher severity; however, it was downgraded to low severity and considered by the judge in scoring. It is being included here for completeness.* + +Allowing to modify the delay period without minimum threshold can result in set the `delay` period to a too short period or even zero ,which is allowed according to the code , and this could lead to allowing a malicious `ScheduleUpgrade` message to pass and a malicious account can gain control over all the contracts of the protocols. + +### Proof of Concept +In the `Root.sol ` contract, the protocol implements a maximum threshold in the function `file()` which can modify the `delay` variable, so the possibility of the set the delay period to a very long period exists and also setting the delay period to a very short period is still possible, as shown in the `file()` which will not prevent set the `delay` period to zero or any short period : +```solidity + function file(bytes32 what, uint256 data) external auth { + if (what == "delay") { + require(data <= MAX_DELAY, "Root/delay-too-long"); + delay = data; + } else { + revert("Root/file-unrecognized-param"); + } + emit File(what, data); +``` +So if the `delay` was set to a very short period and Someone gains control over a router and triggers a malicious `ScheduleUpgrade` message , the malicious attacker can trigger the `executeScheduledRely()` function after the `delay` period, which is too short, to give his address or any contract an `auth` role on any of the protocol contracts, the attacker can be `auth` on the `escrow` contract and steals all the funds from the protocol. + +This attack does not assume that the `auth` admin on the `Root` contract is malicious, but it is all about the possibility of setting the `delay` period to a very short period or even zero, which can be happened mistakenly or intentionally. + +### Recommended Mitigation Steps +This vulnerability can be mitigated by implementing a minimum threshold `MIN_DELAY` to the `delay` period, so this will prevent setting the `delay` period to zero or even a very short period. +```diff ++ uint256 private MIN_DELAY = 2 days ; + + function file(bytes32 what, uint256 data) external auth { + if (what == "delay") { + require(data <= MAX_DELAY, "Root/delay-too-long"); ++ require(data >= MIN_DELAY, "Root/delay-too-short"); + + delay = data; + } else { + revert("Root/file-unrecognized-param"); + } + emit File(what, data); +``` + +## [N-01] Unnecessary checks can be removed, to enhance the code + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L310 + +The `require` keyword can be removed in the function `deployLiquidityPool ()`, because the function `isAllowedAsPoolCurrency` revert on failure in case of the currency is not supported and always return true on success, so if the check fails the function will revert without executing the `require` key word. +``` + function isAllowedAsPoolCurrency(uint64 poolId, address currencyAddress) public view returns (bool) { + uint128 currency = currencyAddressToId[currencyAddress]; + require(currency != 0, "PoolManager/unknown-currency"); // Currency index on the Centrifuge side should start at 1 + require(pools[poolId].allowedCurrencies[currencyAddress], "PoolManager/pool-currency-not-allowed"); + return true; +``` +```solidity + function deployLiquidityPool(uint64 poolId, bytes16 trancheId, address currency) public returns (address) { + Tranche storage tranche = pools[poolId].tranches[trancheId]; + require(tranche.token != address(0), "PoolManager/tranche-does-not-exist"); // Tranche must have been added +@> require(isAllowedAsPoolCurrency(poolId, currency), "PoolManager/currency-not-supported"); // Currency must be supported by pool + + address liquidityPool = tranche.liquidityPools[currency]; + require(liquidityPool == address(0), "PoolManager/liquidityPool-already-deployed"); + require(pools[poolId].createdAt != 0, "PoolManager/pool-does-not-exist"); + +``` + +## [N-02] The `destination` address should be emitted in the userEscrow contract in the function `transferOut` + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/UserEscrow.sol#L49 + +Due to that the receiver can be different address from the user address , the `destination` address should be emitted. +```solidity + function transferOut(address token, address destination, address receiver, uint256 amount) external auth { + require(destinations[token][destination] >= amount, "UserEscrow/transfer-failed"); + require( + receiver == destination || (ERC20Like(token).allowance(destination, receiver) == type(uint256).max), + "UserEscrow/receiver-has-no-allowance" + ); + destinations[token][destination] -= amount; + + SafeTransferLib.safeTransfer(token, receiver, amount); + emit TransferOut(token, receiver, amount); + } +``` + +## [N-03] The `getTranche` function should be created in the `poolManager` contract and used to get the `tranche` from the storage by specifying the `poolId` and `trancheId` + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L308 + +The function `getTranche()` will increase the simplicity and the modularity of the code instead of get the tranche from the storage each time in different functions. +```solidity +function getTranche (uint64 poolId , bytes16 trancheId) private returns ( Tranche storage tranche ){ + tranche = pools[poolId].tranches[trancheId]; +``` +This function can be used instead of the line of code in the poolManager. +```solidity + Tranche storage tranche = pools[poolId].tranches[trancheId]; + +``` + +## [N-04] The check of the creation of the liquidity pool in the function `deployLiquidityPool()` in the PoolManager contract can be removed + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L309 + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L314 + +The `require` statement that checks that the pool is created can be removed since there is a check before it that make sure that the tranche token is deployed and the address of it is stored in the `pool` struct which be be stored only after the pool has been created, so the check of the deployment of the tranche token do the same check of the creation of the pool, so the second check can be removed. + +The check in the function `addTranche()`, which make sure that the pool is created. +```solidity + function addTranche( + uint64 poolId, + bytes16 trancheId, + string memory tokenName, + string memory tokenSymbol, + uint8 decimals + ) public onlyGateway { + Pool storage pool = pools[poolId]; +@> require(pool.createdAt != 0, "PoolManager/invalid-pool"); + Tranche storage tranche = pool.tranches[trancheId]; + require(tranche.createdAt == 0, "PoolManager/tranche-already-exists"); +``` + +And the check of the creation of the pool in the function `deployLiquidityPool()`, which make sure that the tranche token is added and deployed. +```solidity + function deployLiquidityPool(uint64 poolId, bytes16 trancheId, address currency) public returns (address) { + Tranche storage tranche = pools[poolId].tranches[trancheId]; + require(tranche.token != address(0), "PoolManager/tranche-does-not-exist"); // Tranche must have been added + require(isAllowedAsPoolCurrency(poolId, currency), "PoolManager/currency-not-supported"); // Currency must be supported by pool + + + address liquidityPool = tranche.liquidityPools[currency]; + require(liquidityPool == address(0), "PoolManager/liquidityPool-already-deployed"); + require(pools[poolId].createdAt != 0, "PoolManager/pool-does-not-exist"); +``` +So the check `require(pools[poolId].createdAt != 0, "PoolManager/pool-does-not-exist");` can be removed. + +## [N-05] The function `updateMember()` change the state of the contract, but there is no emitted event, so an event should be emitted + +The function `updateMember` in the restriction manager contract should emit the event `updatedMember()` with the right parameters.
+https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/token/RestrictionManager.sol#L57-L60 +```solidity + function updateMember(address user, uint256 validUntil) public auth { + require(block.timestamp <= validUntil, "RestrictionManager/invalid-valid-until"); + members[user] = validUntil; + } +``` + +## [N-06] The `poolManager` should emit events in case of transfer the tokens to the centrifuge chains + +In the functions `transferTrancheTokensToCentrifuge`, `transferTrancheTokensToEVM` and `transfer()` which burn the tokens and transfer the assets, which is a crucial part of the contract that should emit events in case of transfer of the assets and the burn of the tokens. + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L136-L147 + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L149-L163 + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/PoolManager.sol#L128-L134 + +## [N-07] `checkTransferRestriction` check can be removed because the tranche token `transferFrom` function do this check before transfer the tokens + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/InvestmentManager.sol#L473
+https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/InvestmentManager.sol#L474-L477 +```solidity + require(lPool.checkTransferRestriction(msg.sender, user, 0), "InvestmentManager/trancheTokens-not-a-member"); + require( + lPool.transferFrom(address(escrow), user, trancheTokenAmount), + "InvestmentManager/trancheTokens-transfer-failed" + ); +``` +The `checkTransferRestriction` is performed in the `transferFrom` call of the tranche token. + +## [N-08] Consider adding `getLpValues()` to get the lpValuse from the orderBook to enhance the code + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/InvestmentManager.sol#L247 + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/InvestmentManager.sol#L268 + +https://github.com/code-423n4/2023-09-centrifuge/blob/512e7a71ebd9ae76384f837204216f26380c9f91/src/InvestmentManager.sol#L561 + +The contract repeats the code of getting the lpValuse from the storage more than 10 times so the function `getLpValues()` should be added. +```solidity +function _getLpValues(address user , address liquidityPool) private retruns ( LPValues storage lpValues ) { + lpValues = orderbook[user][liquidityPool]; +} +``` + +**[hieronx (Centrifuge) confirmed](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/548#issuecomment-1723506341)** + + + +*** + +# Audit Analysis + +For this audit, 20 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The [report highlighted below](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/316) by **ciphermarco** received the top score from the judge. + +*The following wardens also submitted reports: [cats](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/719), [Sathish9098](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/716), [kaveyjoe](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/657), [0xnev](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/555), [0xbrett8571](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/480), [catellatech](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/326), [jaraxxus](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/782), [Kral01](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/747), [fouzantanveer](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/677), [0xmystery](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/646), [K42](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/616), [hals](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/598), [grearlake](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/588), [rokinot](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/574), [castle\_chain](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/572), [lsaudit](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/571), [emerald7017](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/508), [0x3b](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/428), and [foxb868](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/266).* + +### 1. Executive Summary + +For this analysis, I will center my attention on the scope of the ongoing audit `2023-09-centrifuge`. I begin by outlining the code audit approach employed for the in-scope contracts, followed by sharing my perspective on the architecture, and concluding with observations about the implementation's code. + +**Please note** that unless explicitly stated otherwise, any architectural risks or implementation issues mentioned in this document are not to be considered vulnerabilities or recommendations for altering the architecture or code solely based on this analysis. As an auditor, I acknowledge the importance of thorough evaluation for design decisions in a complex project, taking associated risks into consideration as one single part of an overarching process. It is also important to recognise that the project team may have already assessed these risks and determined the most suitable approach to address or coexist with them. + +### 2. Code Audit Approach + +Time spent: 18 hours + +**2.1 Audit Documentation and Scope** + +The initial step involved examining [audit documentation and scope](https://github.com/code-423n4/2023-09-centrifuge) to grasp the audit's concepts and boundaries, and prioritise my efforts. It is worth highlighting the good quality of the `README` for this audit, as it provides valuable insights and actionable guidance that greatly facilitate the onboarding process for auditors. + +**2.2 Setup and Tests** + +Setting up to execute `forge test` was remarkably effortless, greatly enhancing the efficiency of the auditing process. With a fully functional test harness at our disposal, we not only accelerate the testing of intricate concepts and potential vulnerabilities but also gain insights into the developer's expectations regarding the implementation. Moreover, we can deliver +more value to the project by incorporating our proofs of concept into its tests wherever feasible. + +**2.3 Code review** + +The code review commenced with understanding the "`ward` pattern" used to manage authorization accross the system. Thoroughly understanding this pattern made understanding the protocol contracts and its relations much smoother. Throughout this stage, I documented observations and raised questions concerning potential exploits without going too deep. + +**2.4 Threat Modelling** + +I began formulating specific assumptions that, if compromised, could pose security risks to the system. This process aids me in determining the most effective exploitation strategies. While not a comprehensive threat modeling exercise, it shares numerous similarities with one. + +**2.5 Exploitation and Proofs of Concept** + +From this step forward, the main process became a loop conditionally encompassing each of the steps 2.3, 2.4, and 2.5, involving attempts at exploitation and the creation of proofs of concept with a little help of documentation or the ever helpful sponsors on Discord. My primary objective in this phase is to challenge critical assumptions, generate new ones in the process, and enhance this process by leveraging coded proofs of concept to expedite the development of successful exploits. + +**2.6 Report Issues** + +Alghough this step may seem self-explanatory, it comes with certain nuances. Rushing to report vulnerabilities immediately and then neglecting them is unwise. The most effective approach to bring more value to sponsors (and, hopefully, to auditors) is to document what can be gained by exploiting each vulnerability. This assessment helps in evaluating whether these exploits can be strategically combined to create a more significant impact on the system's security. In some cases, seemingly minor and moderate issues can compound to form a critical vulnerability when leveraged wisely. This has to be balanced with any risks that users may face. In the context of Code4rena audits, zero-days or highly sensitive bugs affecting deployed contracts are given a more cautious and immediate reporting channel. + +### 3. Architecture overview + +**3.1 `Ward` Pattern** + +In grasping the audited architecture, one must comprehend some simple yet potent principles. The first concept is the `ward` pattern, employed to oversee authorisation throughout the protocol. I consider it an elegant and secure method. Below is the 'ward' graph supplied by the Centrifuge team: + +![Wards Overview](https://raw.githubusercontent.com/code-423n4/2023-09-centrifuge/main/images/wards.png?raw=true) + +And, to better contextualise, this is a quote from the audit's `README`: + +``` +The Root contract is a ward on all other contracts. The PauseAdmin can instantaneously pause the protocol. The DelayedAdmin can make itself ward on any contract through Root.relyContract, but this needs to go through the timelock specified in Root.delay. The Root.delay will initially be set to 48 hours. +``` + +So how does this work in practice? + +As `Root` is a ward on all other contracts, any ward on `Root` can use its `relyContract` function to become a ward in any of these contracts: + +https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/Root.sol#L90-L93: +``` + function relyContract(address target, address user) public auth { + AuthLike(target).rely(user); + emit RelyContract(target, user); + } +``` + +The opposite being the `denyContract` function: + +https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/Root.sol#L98-L102: +``` + function denyContract(address target, address user) public auth { + AuthLike(target).deny(user); + emit DenyContract(target, user); + } +} +``` + +The term "rely" is employed to signify "becoming a ward." Thus, when I state that contract X relies on contract Y, it implies that contract Y assumes the role of a ward within contract X. Conversely, the verb "deny" is utilised to describe the opposing action. + +Contracts within the system are anticipated to inherit (or implement) the [`Auth` contract](https://github.com/code-423n4/2023-09-centrifuge/blob/main/src/util/Auth.sol). The `Auth` contract is exceptionally minimal, and for your reference, I have included it below: + +``` +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) Centrifuge 2020, based on MakerDAO dss https://github.com/makerdao/dss +pragma solidity 0.8.21; + +/// @title Auth +/// @notice Simple authentication pattern +contract Auth { + mapping(address => uint256) public wards; + + event Rely(address indexed user); + event Deny(address indexed user); + + /// @dev Give permissions to the user + function rely(address user) external auth { + wards[user] = 1; + emit Rely(user); + } + + /// @dev Remove permissions from the user + function deny(address user) external auth { + wards[user] = 0; + emit Deny(user); + } + + /// @dev Check if the msg.sender has permissions + modifier auth() { + require(wards[msg.sender] == 1, "Auth/not-authorized"); + _; + } +} +``` + +With these capabilities at their disposal, any `ward` in `Root` has the ability to invoke `root.allowContract(address target, address user)`. This action designates `user` — whether an Externally Owned Account (EOA) or Contract — as a `ward` within the `contract`. As a result, the `Root`'s existing `wards` can execute functions within any of the contracts to which `Root` serves as a `ward` (i.e. all other contracts in the system). + +**3.1.1 The `PauseAdmin`** + +The `PauseAdmin` represents a concise contract equipped with functions to manage `pausers`, those who can call its `pause` function. Its `pause` function, in turn, invokes the `Root.pause` function to suspend protocol operations. We will shortly delve into the mechanics of this pausing process. To facilitate the ability to call `Root.pause`, `PauseAdmin` holds the status of a `ward` within the `Root` contract. + +**3.1.2 The `DelayedAdmin`** + +The `DelayedAdmin` is another concise contract with the capability to pause and unpause the protocol. Additionally, it possesses the ability, after a `delay` set in the `Root` contract by its deployer (the first ward) and other wards, to designate itself or other Externally Owned Accounts (EOAs) or contracts as `wards` within the `Root` contract. This delay is enforced due to the `DelayedAdmin` implementing a function to invoke `root.scheduleRely`. The `root.scheduleRely` function is responsible for scheduling an address to become a `ward` within the `Root` contract. + +The `pauseAdmin.scheduleRely` function: + +``` + // PauseAdmin.sol + function scheduleRely(address target) public auth { + root.scheduleRely(target); + } +``` + +Which calls the `root.scheduleRely`: + +``` + // Root.sol + function scheduleRely(address target) external auth { + schedule[target] = block.timestamp + delay; + emit RelyScheduled(target, schedule[target]); + } +``` + +As per the documentation provided, the initial setting for this `delay` is 48 hours. Once a rely is scheduled, it can subsequently be cancelled using the `delayedAdmin.cancelRely` function: + +``` + // PauseAdmin.sol + function cancelRely(address target) public auth { + root.cancelRely(target); + } +``` + +That function calls the `root.cancelRely`: + +``` + // Root.sol + function cancelRely(address target) external auth { + schedule[target] = 0; + emit RelyCancelled(target); + } +``` + +Or executed to make it effective by `executeScheduledRely` in the `Root` contract after the `delay` has passed: + +``` + // Root.sol + function executeScheduledRely(address target) public { + require(schedule[target] != 0, "Root/target-not-scheduled"); + require(schedule[target] < block.timestamp, "Root/target-not-ready"); + + wards[target] = 1; + emit Rely(target); + + schedule[target] = 0; + } +``` + +**3.1.3 A Problem with `DelayedAdmin`** + +Whilst conducting a comparison between the audit documentation and the system, I identified a critical discrepancy in the `DelayedAdmin`. The project team outlined several potential emergency scenarios to aid auditors in comprehending the protocol's security model. One of these scenarios is outlined below: + +>"**Someone controls 1 pause admin and triggers a malicious `pause()`** +> +>* The delayed admin is a `ward` on the pause admin and can trigger `PauseAdmin.removePauser`. +>* It can then trigger `root.unpause()`." + +The issue I have identified is that the `DelayedAdmin`, in its current form, does not incorporate any functions to `PauseAdmin.removePauser`. Given that I believe this deviates from a critical aspect of the protocol's security model, I have chosen to report this discovery with a Medium severity rating. However, I acknowledge that it could be argued as more appropriate for a Low severity rating if it is ultimately deemed a mere deviation from the specification. I have expressed my viewpoint in the issue report, and I am confident that it will be evaluated with care and a comprehensive consideration of the real-world risks it may present, which might diverge from my own assessment. + +Following discussions with sponsors to gain deeper insights into the implications of a `DelayedAdmin` unable to execute `pauseAdmin.removePause`, they exposed two workarounds to address this issue. These workarounds do not alter my stance on the severity within the context of an audit competition but offer valuable perspectives from the project team to enhance our understanding of the system's security model and its ability to withstand unforeseen failures. + +**3.1.3.1 The Delayed Rescue** + +In a delayed rescue the project team could use the `DelayedAdmin` to: + +1. Call `root.scheduleRely` to make a contract or EOA (let's call it `delayedRescuer`) an `ward` in the `Root` contract; +2. Wait the set `delay`, initially be set to 48 hours, to pass; +3. Use `Rescuer` to call `root.relyContract(address(pauseAdmin), address(delayedRescuer))` to become a ward on the `pauseAdmin`; +4. Finally call `pauseAdmin.removePauser(address(maliciousPauser))` to remove the malicious `pauser`. + +It is elegant but comes with the drawback of having to wait for the `delay` set in the `Root` contract, which is not the intended design of this setup. + +**3.1.3.2 The Prompt Rescue** + +In the prompt rescue, a contract (let's call it `promptRescuer`) could be made a ward in the `root` contract to: + +1. Call `root.relyContract(address(pauseAdmin), address(promptRescuer))`; +2. Call `pauseAdmin.removePauser(address(maliciousPauser))`; +3. Possibly call `root.denyContract(address(promptRescuer))`; + +In `Root`, when one contract designates another as its ward, it always introduces certain risks. However, by employing the Spell pattern, it significantly mitigates at least one of these risks. + +**3.2 The Spell Pattern** + +Having understood how these two admins fit into the `ward` pattern, and analysing the rescue operation, shows how simple and potentially powerful the pattern can be. But to realise its power in a more secure manner, we need to enter the Spell pattern. + +References to spells can be identified within the codebase's tests, and the sponsors have publicly shared their intentions through the audit competition's Discord channel. When utilising `root.relyContract`, it is essential to anticipate that the intended targets for the `wards` in `Root` will employ the [spell pattern from MakerDAO](https://docs.makerdao.com/smart-contract-modules/governance-module/spell-detailed-documentation). Besides implementation details and other factors to consider, the utmost detail to retain in the context of this analysis is that *"a spell can only be cast once"*. + +By joining the project's `ward` pattern with the Spell pattern, we can fully understand how critical security operations are expected to flow throughout the system. + +**3.3 Pausing the Protocol** + +As mentioned before, the `PauseAdmin` has the responsibility to manage the `pausers` who can pause the protocol. We have seen that the `pauseAdmin`, triggered by one of its `pausers`, calls the `pause` function in the `Root` contract. The `Root` contract then sets the variable `bool public paused` to `true`. Here is the `root.pause` function's implementation: + +``` + // Root.sol + function pause() external auth { + paused = true; + emit Pause(); + } +``` + +But how can it pause the whole protocol? + +The protocol's flow of communication uses a hub-and-spoke model with the `Gateway` in the middle. According to the audit's `README` documentation, the `Gateway` contract is an intermediary contract that encodes and decodes messages using the `Message` contract, and handles routing to and from Centrifuge. This is the graph made available by the project team: + +![High Level Contract Overview](https://github.com/code-423n4/2023-09-centrifuge/blob/main/images/contracts.png?raw=true) + +The `Gateway` implements the `pauseable` modifier: + +``` + // Gateway.sol + modifier pauseable() { + require(!root.paused(), "Gateway/paused"); + _; + } +``` + +This modifier is employed in all critical incoming and outgoing operations within the `Gateway`. Then if `root.paused` is set to `true`, it halts the "hub" by blocking these operations, thus interrupting the flow of communication in the protocol. + +**3.3.1 Redundancy?** + +It is important to highlight that both `DelayedAdmin` and `PauseAdmin` have the capability to invoke `root.pause` directly in order to pause the protocol. What might initially appear as redundancy seems to be, in fact, a well-considered implementation of secure compartmentalization. + +`DelayedAdmin` possesses a broader range of powers beyond the functions to `pause` and `unpause` the protocol. In contrast, `PauseAdmin` primarily oversees `pausers` with the singular purpose of allowing them to pause the protocol. Considering that `DelayedAdmin` has the potential to cause more harm to the protocol, it should be subject to a stricter oversight, while `pausers` may adhere to a less stringent regime. While `PauseAdmin` can disrupt the protocol and cause damage by initiating pauses, this impact pales in comparison to what `DelayedAdmin` +could potentially do if not restricted during the `Root`'s `delay`. + +**3.4 Liquidity Pools** + +Supported by this architecture lies the liquidity pools. These liquidity pools can be deployed on any EVM-compatible blockchain, whether it's on a Layer 1 or Layer 2, in response to market demand. All of these systems are ultimately interconnected with the Centrifuge chain, which holds the responsibility of managing the Real-World Assets (RWA) pools: + +![Liquidity Pools](https://gist.githubusercontent.com/ciphermarco/438959a403457ab4bb4fc54838cde63c/raw/4a6da758fcb013c6a954d9309634c972034b1a0e/liquidity-pools.png) + +Every tranche, symbolized by a Tranche Token (TT), represents varying levels of risk exposure for investors. For an overview of the authorization relations, you can refer to the [Ward Pattern section](#31-ward-pattern), and to understand the communication flow, visit the [Pausing the Protocol section](#33-pausing-the-protocol). + +**3.5 Upgrading the Protocol** + +Another interesting aspect of the architecture is the deliberate choice to use contract migrations instead of upgradeable proxy patterns. Upgrades to the protocol can be achieved by utilising the Spell pattern to re-organize `wards` and "rely" links across the system. To initiate an upgrade, a Spell contract is scheduled through the `root.scheduleRely` function. After the specified `delay` period, the upgrade can be executed by using `root.executeScheduledRely` to complete its tasks in a one-shot fashion. Should the need to cancel the upgrade arise, `root.cancelRely` can be called to cancel the scheduled rely operation." + +**3.6 Centralisation Risks** + +In the context of this analysis, it is imperative to underscore that the usage of "centralisation" specifically pertains to the potential single point of failure within the project team's assets, such as the scenario where only one key needs to be compromised in order to compromise the whole system. I am not delving into concerns associated with the likelihood of collusion amongst key personnel. + +The most significant concern regarding centralisation lies in the `Root` contract's `wards`. Therefore, it is highly recommended to manage these `wards` through multi-signature schemes to mitigate the risk of a single point of failure within the system. This encompasses any contract that could potentially become a `ward` in `Root`, including contracts like `DelayedAdmin` despite its temporary limitation of having to await the `delay` set in the `Root` contract. + +Another noteworthy potential single point of failure lies in the `Router` contract, should it become compromised, as mentioned in the audit's `README` documentation. This situation can be rectified through a sequence of operations involving the `PauseAdmin` and `DelayedAdmin`, or even solely relying on the `DelayedAdmin`'s capabilities. + +There are other critical entities within the architecture that have the potential to act as single points of failure, leading to varying degrees of damage. However, for the specific use case at hand, I find the existing tools and safeguards to be adequate for mitigating risks, particularly when keys are safeguarded through multi-signature schemes and other strategies that require careful consideration. These aspects are beyond the scope of this analysis; the same applies to what occurs within the Centrifuge chain. + +### 4. Implementation Notes + +Throughout the audit, certain implementation details stood out as note worthy, and a portion of these could prove valuable for this analysis. + +**4.1 General Impressions** + +The codebase stood to what is promised in the audit's `README`, so I will just paste it here: + +> The coding style of the liquidity-pools code base is heavily inspired by MakerDAO's coding style. Composition over inheritance, no upgradeable proxies but rather using contract migrations, and as few dependencies as possible. Authentication uses the ward pattern, in which addresses can be relied or denied to get access. Key parameter updates of contracts are executed through file methods. + +It was genuinely enjoyable to review this codebase. The code has been meticulously designed to prioritise simplicity. It strikes the right balance of complexity, avoiding unnecessary complications. + +**4.2 Composition over Inheritance** + +The codebase's pivotal choice of favoring composition over inheritance, coupled with thoughtful coding practices, has allowed for the creation of clean and very comprehensible code, free from convoluted inheritance hierarchies that confuse even the project developers themselves. The significance of this choice in enhancing the security of the protocol cannot be overstated. Clear and well-organised code not only prevents the introduction of vulnerabilities but also simplifies the process of detecting and resolving any potential issues. And, my opinion is that opting for composition over inheritance significantly contributed to achieving this objective. + +**4.3 Tests** + +As mentioned in **2.2 Setup and Tests**, executing the tests using `forge` was effortless, significantly expediting our work as auditors. Running `forge coverage` also provides a convenient table summarizing the code coverage for these tests: + +``` +$ forge coverage +[... snip ...] +| File | % Lines | % Statements | % Branches | % Funcs | +|---------------------------------------|-------------------|-------------------|------------------|------------------| +| script/Axelar.s.sol | 0.00% (0/32) | 0.00% (0/35) | 0.00% (0/2) | 0.00% (0/2) | +| script/Deployer.sol | 0.00% (0/40) | 0.00% (0/42) | 100.00% (0/0) | 0.00% (0/4) | +| script/Permissionless.s.sol | 0.00% (0/20) | 0.00% (0/21) | 0.00% (0/2) | 0.00% (0/2) | +| src/Escrow.sol | 100.00% (2/2) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (1/1) | +| src/InvestmentManager.sol | 97.37% (185/190) | 96.47% (246/255) | 61.11% (55/90) | 97.62% (41/42) | +| src/LiquidityPool.sol | 100.00% (64/64) | 100.00% (86/86) | 100.00% (4/4) | 100.00% (38/38) | +| src/PoolManager.sol | 95.96% (95/99) | 94.59% (105/111) | 74.07% (40/54) | 94.12% (16/17) | +| src/Root.sol | 100.00% (22/22) | 100.00% (22/22) | 100.00% (8/8) | 100.00% (8/8) | +| src/UserEscrow.sol | 100.00% (8/8) | 100.00% (8/8) | 100.00% (4/4) | 100.00% (2/2) | +| src/admins/DelayedAdmin.sol | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (4/4) | +| src/admins/PauseAdmin.sol | 100.00% (5/5) | 100.00% (5/5) | 100.00% (0/0) | 100.00% (3/3) | +| src/gateway/Gateway.sol | 93.42% (71/76) | 91.86% (79/86) | 76.47% (26/34) | 100.00% (17/17) | +| src/gateway/Messages.sol | 59.26% (96/162) | 59.77% (153/256) | 16.67% (1/6) | 55.00% (44/80) | +| src/gateway/routers/axelar/Router.sol | 100.00% (8/8) | 100.00% (9/9) | 100.00% (4/4) | 100.00% (3/3) | +| src/gateway/routers/xcm/Router.sol | 0.00% (0/38) | 0.00% (0/46) | 0.00% (0/20) | 0.00% (0/9) | +| src/token/ERC20.sol | 97.40% (75/77) | 96.51% (83/86) | 95.00% (38/40) | 93.33% (14/15) | +| src/token/RestrictionManager.sol | 100.00% (15/15) | 100.00% (17/17) | 80.00% (8/10) | 100.00% (6/6) | +| src/token/Tranche.sol | 100.00% (18/18) | 100.00% (31/31) | 100.00% (4/4) | 75.00% (9/12) | +| src/util/Auth.sol | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (2/2) | +| src/util/BytesLib.sol | 0.00% (0/28) | 0.00% (0/28) | 0.00% (0/16) | 0.00% (0/7) | +| src/util/Context.sol | 100.00% (1/1) | 100.00% (1/1) | 100.00% (0/0) | 100.00% (1/1) | +| src/util/Factory.sol | 100.00% (24/24) | 100.00% (37/37) | 100.00% (0/0) | 100.00% (3/3) | +| src/util/MathLib.sol | 0.00% (0/30) | 0.00% (0/38) | 0.00% (0/6) | 0.00% (0/3) | +| src/util/SafeTransferLib.sol | 100.00% (7/7) | 100.00% (9/9) | 66.67% (4/6) | 100.00% (3/3) | +| test/TestSetup.t.sol | 0.00% (0/49) | 0.00% (0/72) | 0.00% (0/6) | 0.00% (0/9) | +| test/mock/AxelarGatewayMock.sol | 100.00% (4/4) | 100.00% (4/4) | 100.00% (0/0) | 100.00% (2/2) | +| test/mock/GatewayMock.sol | 2.13% (1/47) | 2.13% (1/47) | 100.00% (0/0) | 9.09% (1/11) | +| test/mock/Mock.sol | 25.00% (1/4) | 25.00% (1/4) | 100.00% (0/0) | 25.00% (1/4) | +| Total | 65.86% (710/1078) | 66.40% (907/1366) | 62.03% (196/316) | 70.65% (219/310) | +``` + +Coverage, while not providing a comprehensive view of what is tested and how thoroughly, can serve as a valuable indicator of the project's commitment to testing practices. Based on the coverage within the audit's scope and my closer assessment of the test files, I deem the testing practices fitting for this stage of development. + +**4.4 Comments** + +As previously mentioned, the code is generally clean and strategically straightforward; even so, comments can further enhance the experience for both auditors and developers. In the context of this codebase, I believe comments are generally effective in delivering value to readers; however, there are a few sections that could benefit from additional comments. + +I expect special attention to more detailed comments in mathematical and pricing operations like, such as those found in `InvestmentManager.convertToShares` and `InvestmentManager.converToAssets`. While these operations are not inherently complex, such comments are crucial because there are two phases of finding bugs in mathematical operations in a code. Usually, the easier one for most auditors is to spot a discrepancy between the intention the comments documenting the code transmit and the code itself. The other one is questioning the mathematical foundations and correctness of the formulas used. Being specific when commenting on the expected outcomes of calculations within the code greatly aids to address the former. + +**4.5 Solidity Versions** + +When evaluating the quality of a codebase, it is a sensible approach to examine the range of Solidity versions accepted throughout the codebase: + +``` +$ grep --include \*.sol --exclude-dir forge-std -hr "pragma solidity" | sort | uniq -c | sort -n + 1 pragma solidity ^0.8.20; + 1 // pragma solidity 0.8.21; + 47 pragma solidity 0.8.21; +``` + +The majority of our codebase currently relies on version `0.8.21`, a choice that I consider highly advantageous. And, using `^0.8.20` is obviously not a problem. + +Whilst it is true that there are valid points both in favor of and against adopting the latest Solidity version, I believe this debate holds little relevance for the project at this stage. Opting for the most recent version is unquestionably a superior choice over potentially risky outdated versions. + +### 5. Conclusion + +Auditing this codebase and its architectural choices has been a delightful experience. Inherently complex systems greatly benefit from strategically implemented simplifications, and I believe this project has successfully struck a harmonious balance between the imperative for simplicity and the challenge of managing complexity. I hope that I have been able to offer a valuable overview of the methodology utilised during the audit of the contracts within scope, along with pertinent insights for the project team and any party interested in analysing this codebase. + +**Time spent:**
+18 hours + +**[hieronx (Centrifuge) acknowledged](https://github.com/code-423n4/2023-09-centrifuge-findings/issues/316#issuecomment-1723507559)** + + + +*** + + +# Disclosures + +C4 is an open organization governed by participants in the community. + +C4 Audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification. + +C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users. \ No newline at end of file diff --git a/audits/2023-09-SRLabs.pdf b/audits/2023-09-SRLabs.pdf new file mode 100644 index 00000000..30ae11b5 Binary files /dev/null and b/audits/2023-09-SRLabs.pdf differ diff --git a/audits/2023-10-Spearbit-Cantina-Managed.pdf b/audits/2023-10-Spearbit-Cantina-Managed.pdf new file mode 100644 index 00000000..460ab4bc Binary files /dev/null and b/audits/2023-10-Spearbit-Cantina-Managed.pdf differ diff --git a/foundry.toml b/foundry.toml index 86dcd944..e85e759e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,7 +7,7 @@ solc_version = "0.8.21" evm_version = "paris" # to prevent usage of PUSH0, which is not supported on all chains optimizer = true -optimizer_runs = 10_000 +optimizer_runs = 1_000 verbosity = 3 [profile.default.fuzz] diff --git a/script/Axelar.s.sol b/script/Axelar.s.sol index dd108209..5e748cdd 100644 --- a/script/Axelar.s.sol +++ b/script/Axelar.s.sol @@ -54,12 +54,12 @@ contract AxelarScript is Deployer { LiquidityPoolLike liquidityPool = LiquidityPoolLike( poolManager.getLiquidityPool(1171854325, 0x102f4ef817340a8839a515d2c73a7c1d, address(currency)) ); - currency.approve(address(investmentManager), 10000 * 10 ** decimals); - liquidityPool.requestDeposit(200 * 10 ** decimals, msg.sender); - liquidityPool.requestDeposit(200 * 10 ** decimals, msg.sender); - liquidityPool.requestDeposit(200 * 10 ** decimals, msg.sender); - liquidityPool.requestDeposit(200 * 10 ** decimals, msg.sender); - liquidityPool.requestDeposit(200 * 10 ** decimals, msg.sender); + currency.approve(address(liquidityPool), 1000 * 10 ** 18); + liquidityPool.requestDeposit(200 * 10 ** 18, msg.sender); + liquidityPool.requestDeposit(200 * 10 ** 18, msg.sender); + liquidityPool.requestDeposit(200 * 10 ** 18, msg.sender); + liquidityPool.requestDeposit(200 * 10 ** 18, msg.sender); + liquidityPool.requestDeposit(200 * 10 ** 18, msg.sender); } giveAdminAccess(); diff --git a/script/Deployer.sol b/script/Deployer.sol index 77481575..c56a3a4d 100644 --- a/script/Deployer.sol +++ b/script/Deployer.sol @@ -43,8 +43,6 @@ contract Deployer is Script { userEscrow = new UserEscrow(); root = new Root{salt: salt}(address(escrow), delay, deployer); - investmentManager = new InvestmentManager(address(escrow), address(userEscrow)); - address liquidityPoolFactory = address(new LiquidityPoolFactory(address(root))); address restrictionManagerFactory = address(new RestrictionManagerFactory(address(root))); address trancheTokenFactory = address(new TrancheTokenFactory{salt: salt}(address(root), deployer)); diff --git a/src/InvestmentManager.sol b/src/InvestmentManager.sol index 323e0da0..201c3d6a 100644 --- a/src/InvestmentManager.sol +++ b/src/InvestmentManager.sol @@ -6,18 +6,16 @@ import {MathLib} from "./util/MathLib.sol"; import {SafeTransferLib} from "./util/SafeTransferLib.sol"; interface GatewayLike { - function increaseInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency, uint128 amount) + function increaseInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId, uint128 amount) external; - function decreaseInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency, uint128 amount) + function decreaseInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId, uint128 amount) external; - function increaseRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency, uint128 amount) + function increaseRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId, uint128 amount) external; - function decreaseRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency, uint128 amount) + function decreaseRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId, uint128 amount) external; - function collectInvest(uint64 poolId, bytes16 trancheId, address investor, uint128 currency) external; - function collectRedeem(uint64 poolId, bytes16 trancheId, address investor, uint128 currency) external; - function cancelInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency) external; - function cancelRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currency) external; + function cancelInvestOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId) external; + function cancelRedeemOrder(uint64 poolId, bytes16 trancheId, address investor, uint128 currencyId) external; } interface ERC20Like { @@ -34,13 +32,12 @@ interface TrancheTokenLike is ERC20Like { } interface LiquidityPoolLike is ERC20Like { - function poolId() external returns (uint64); - function trancheId() external returns (bytes16); + function poolId() external view returns (uint64); + function trancheId() external view returns (bytes16); function asset() external view returns (address); function share() external view returns (address); - function hasMember(address) external returns (bool); - function updatePrice(uint128 price) external; - function latestPrice() external view returns (uint128); + function emitDepositClaimable(address operator, uint256 assets, uint256 shares) external; + function emitRedeemClaimable(address operator, uint256 assets, uint256 shares) external; } interface AuthTransferLike { @@ -51,7 +48,11 @@ interface PoolManagerLike { function currencyIdToAddress(uint128 currencyId) external view returns (address); function currencyAddressToId(address addr) external view returns (uint128); function getTrancheToken(uint64 poolId, bytes16 trancheId) external view returns (address); - function getLiquidityPool(uint64 poolId, bytes16 trancheId, address currency) external view returns (address); + function getTrancheTokenPrice(uint64 poolId, bytes16 trancheId, address currencyAddress) + external + view + returns (uint256 price, uint64 computedAt); + function getLiquidityPool(uint64 poolId, bytes16 trancheId, uint128 currencyId) external view returns (address); function isAllowedAsInvestmentCurrency(uint64 poolId, address currencyAddress) external view returns (bool); } @@ -65,7 +66,7 @@ interface UserEscrowLike { } /// @dev Liquidity Pool orders and investment/redemption limits per user -struct LPValues { +struct InvestmentState { /// @dev Tranche tokens that can be claimed using `mint()` uint128 maxMint; /// @dev Weighted average price of deposits, used to convert maxMint to maxDeposit @@ -75,9 +76,11 @@ struct LPValues { /// @dev Weighted average price of redemptions, used to convert maxWithdraw to maxRedeem uint256 redeemPrice; /// @dev Remaining invest (deposit) order in currency - uint128 remainingInvestOrder; + uint128 pendingDepositRequest; /// @dev Remaining redeem order in currency - uint128 remainingRedeemOrder; + uint128 pendingRedeemRequest; + ///@dev Flag whether this user has ever interacted with this liquidity pool + bool exists; } /// @title Investment Manager @@ -85,7 +88,6 @@ struct LPValues { /// both incoming and outgoing investment transactions. contract InvestmentManager is Auth { using MathLib for uint256; - using MathLib for uint128; /// @dev Prices are fixed-point integers with 18 decimals uint8 internal constant PRICE_DECIMALS = 18; @@ -96,37 +98,13 @@ contract InvestmentManager is Auth { GatewayLike public gateway; PoolManagerLike public poolManager; - mapping(address liquidityPool => mapping(address investor => LPValues)) public orderbook; + mapping(address liquidityPool => mapping(address investor => InvestmentState)) public investments; // --- Events --- event File(bytes32 indexed what, address data); - event ExecutedCollectInvest( - uint64 indexed poolId, - bytes16 indexed trancheId, - address recipient, - uint128 currency, - uint128 currencyPayout, - uint128 trancheTokensPayout - ); - event ExecutedCollectRedeem( - uint64 indexed poolId, - bytes16 indexed trancheId, - address recipient, - uint128 currency, - uint128 currencyPayout, - uint128 trancheTokensPayout - ); - event ExecutedDecreaseInvestOrder( - uint64 indexed poolId, bytes16 indexed trancheId, address user, uint128 currency, uint128 currencyPayout - ); - event ExecutedDecreaseRedeemOrder( - uint64 indexed poolId, bytes16 indexed trancheId, address user, uint128 currency, uint128 trancheTokensPayout - ); event TriggerIncreaseRedeemOrder( - uint64 indexed poolId, bytes16 indexed trancheId, address user, uint128 currency, uint128 trancheTokenAmount + uint64 indexed poolId, bytes16 indexed trancheId, address user, address currency, uint128 trancheTokenAmount ); - event DepositCollect(address indexed owner); - event RedeemCollect(address indexed owner); constructor(address escrow_, address userEscrow_) { escrow = EscrowLike(escrow_); @@ -151,40 +129,44 @@ contract InvestmentManager is Auth { } // --- Outgoing message handling --- - /// @notice Request deposit. Liquidity pools have to request investments from Centrifuge before + /// @notice Liquidity pools have to request investments from Centrifuge before /// tranche tokens can be minted. The deposit requests are added to the order book /// on Centrifuge. Once the next epoch is executed on Centrifuge, liquidity pools can /// proceed with tranche token payouts in case their orders got fulfilled. - /// If an amount of 0 is passed, this triggers cancelling outstanding deposit orders. /// @dev The user currency amount required to fulfill the deposit request have to be locked, /// even though the tranche token payout can only happen after epoch execution. - function requestDeposit(address liquidityPool, uint256 currencyAmount, address user) public auth { + function requestDeposit(address liquidityPool, uint256 currencyAmount, address sender, address operator) + public + auth + returns (bool) + { LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); - uint128 _currencyAmount = _toUint128(currencyAmount); + uint128 _currencyAmount = currencyAmount.toUint128(); require(_currencyAmount != 0, "InvestmentManager/zero-amount-not-allowed"); uint64 poolId = lPool.poolId(); - bytes16 trancheId = lPool.trancheId(); address currency = lPool.asset(); - uint128 currencyId = poolManager.currencyAddressToId(currency); - require(poolManager.isAllowedAsInvestmentCurrency(poolId, currency), "InvestmentManager/currency-not-allowed"); + require( - _checkTransferRestriction(liquidityPool, address(0), user, convertToShares(liquidityPool, currencyAmount)), + _checkTransferRestriction(liquidityPool, address(0), sender, 0), "InvestmentManager/sender-is-restricted" + ); + require( + _checkTransferRestriction( + liquidityPool, address(0), operator, convertToShares(liquidityPool, currencyAmount) + ), "InvestmentManager/transfer-not-allowed" ); - // Transfer the currency amount from user to escrow (lock currency in escrow) - // Checks actual balance difference to support fee-on-transfer tokens - uint256 preBalance = ERC20Like(currency).balanceOf(address(escrow)); - SafeTransferLib.safeTransferFrom(currency, user, address(escrow), _currencyAmount); - uint256 postBalance = ERC20Like(currency).balanceOf(address(escrow)); - uint128 transferredAmount = _toUint128(postBalance - preBalance); + InvestmentState storage state = investments[liquidityPool][operator]; + state.pendingDepositRequest = state.pendingDepositRequest + _currencyAmount; + state.exists = true; - LPValues storage lpValues = orderbook[liquidityPool][user]; - lpValues.remainingInvestOrder = lpValues.remainingInvestOrder + transferredAmount; + gateway.increaseInvestOrder( + poolId, lPool.trancheId(), operator, poolManager.currencyAddressToId(currency), _currencyAmount + ); - gateway.increaseInvestOrder(poolId, trancheId, user, currencyId, transferredAmount); + return true; } /// @notice Request tranche token redemption. Liquidity pools have to request redemptions @@ -192,48 +174,62 @@ contract InvestmentManager is Auth { /// requests are added to the order book on Centrifuge. Once the next epoch is /// executed on Centrifuge, liquidity pools can proceed with currency payouts /// in case their orders got fulfilled. - /// If an amount of 0 is passed, this triggers cancelling outstanding redemption orders. /// @dev The user tranche tokens required to fulfill the redemption request have to be locked, /// even though the currency payout can only happen after epoch execution. - function requestRedeem(address liquidityPool, uint256 trancheTokenAmount, address user) public auth { - LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); - uint128 _trancheTokenAmount = _toUint128(trancheTokenAmount); + function requestRedeem(address liquidityPool, uint256 trancheTokenAmount, address operator, address /* owner */ ) + public + auth + returns (bool) + { + uint128 _trancheTokenAmount = trancheTokenAmount.toUint128(); require(_trancheTokenAmount != 0, "InvestmentManager/zero-amount-not-allowed"); - - uint64 poolId = lPool.poolId(); - bytes16 trancheId = lPool.trancheId(); - address currency = lPool.asset(); - uint128 currencyId = poolManager.currencyAddressToId(currency); + LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); // You cannot redeem using a disallowed investment currency, instead another LP will have to be used - require(poolManager.isAllowedAsInvestmentCurrency(poolId, currency), "InvestmentManager/currency-not-allowed"); + require( + poolManager.isAllowedAsInvestmentCurrency(lPool.poolId(), lPool.asset()), + "InvestmentManager/currency-not-allowed" + ); - // Transfer the tranche token amount from user to escrow (lock tranche tokens in escrow) require( - AuthTransferLike(address(lPool.share())).authTransferFrom(user, address(escrow), _trancheTokenAmount), - "InvestmentManager/transfer-failed" + _checkTransferRestriction( + liquidityPool, operator, address(escrow), convertToAssets(liquidityPool, trancheTokenAmount) + ), + "InvestmentManager/transfer-not-allowed" ); - LPValues storage lpValues = orderbook[liquidityPool][user]; - lpValues.remainingRedeemOrder = lpValues.remainingRedeemOrder + _trancheTokenAmount; + return _processRedeemRequest(liquidityPool, _trancheTokenAmount, operator); + } + + function _processRedeemRequest(address liquidityPool, uint128 trancheTokenAmount, address user) + internal + returns (bool) + { + LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); + InvestmentState storage state = investments[liquidityPool][user]; + state.pendingRedeemRequest = state.pendingRedeemRequest + trancheTokenAmount; + state.exists = true; + + gateway.increaseRedeemOrder( + lPool.poolId(), lPool.trancheId(), user, poolManager.currencyAddressToId(lPool.asset()), trancheTokenAmount + ); - gateway.increaseRedeemOrder(poolId, trancheId, user, currencyId, _trancheTokenAmount); + return true; } function decreaseDepositRequest(address liquidityPool, uint256 _currencyAmount, address user) public auth { - uint128 currencyAmount = _toUint128(_currencyAmount); LiquidityPoolLike _liquidityPool = LiquidityPoolLike(liquidityPool); gateway.decreaseInvestOrder( _liquidityPool.poolId(), _liquidityPool.trancheId(), user, poolManager.currencyAddressToId(_liquidityPool.asset()), - currencyAmount + _currencyAmount.toUint128() ); } function decreaseRedeemRequest(address liquidityPool, uint256 _trancheTokenAmount, address user) public auth { - uint128 trancheTokenAmount = _toUint128(_trancheTokenAmount); + uint128 trancheTokenAmount = _trancheTokenAmount.toUint128(); LiquidityPoolLike _liquidityPool = LiquidityPoolLike(liquidityPool); require( _checkTransferRestriction(liquidityPool, address(0), user, _trancheTokenAmount), @@ -260,7 +256,7 @@ contract InvestmentManager is Auth { function cancelRedeemRequest(address liquidityPool, address user) public auth { LiquidityPoolLike _liquidityPool = LiquidityPoolLike(liquidityPool); - uint256 approximateTrancheTokensPayout = userRedeemRequest(liquidityPool, user); + uint256 approximateTrancheTokensPayout = pendingRedeemRequest(liquidityPool, user); require( _checkTransferRestriction(liquidityPool, address(0), user, approximateTrancheTokensPayout), "InvestmentManager/transfer-not-allowed" @@ -273,150 +269,95 @@ contract InvestmentManager is Auth { ); } - /// @notice Trigger collecting the deposited funds. - /// @dev In normal circumstances, this should happen automatically on Centrifuge Chain. - /// This function is only included as a fallback. - function collectDeposit(address liquidityPool, address receiver) public { - LiquidityPoolLike _liquidityPool = LiquidityPoolLike(liquidityPool); - uint256 approximateMaxTrancheTokensPayout = - convertToShares(liquidityPool, userDepositRequest(liquidityPool, receiver)); - require( - _checkTransferRestriction(liquidityPool, address(escrow), receiver, approximateMaxTrancheTokensPayout), - "InvestmentManager/transfer-not-allowed" - ); - gateway.collectInvest( - _liquidityPool.poolId(), - _liquidityPool.trancheId(), - receiver, - poolManager.currencyAddressToId(_liquidityPool.asset()) - ); - } - - /// @notice Trigger collecting the deposited tokens. - /// @dev In normal circumstances, this should happen automatically on Centrifuge Chain. - /// This function is only included as a fallback. - function collectRedeem(address liquidityPool, address receiver) public { - LiquidityPoolLike _liquidityPool = LiquidityPoolLike(liquidityPool); - gateway.collectRedeem( - _liquidityPool.poolId(), - _liquidityPool.trancheId(), - receiver, - poolManager.currencyAddressToId(_liquidityPool.asset()) - ); - } - // --- Incoming message handling --- - /// @notice Update the price of a tranche token - /// @dev This also happens automatically on incoming order executions, - /// but this incoming call from Centrifuge can be used to update the price - /// whenever the price is outdated but no orders are outstanding. - function updateTrancheTokenPrice(uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) - public - onlyGateway - { - address currency = poolManager.currencyIdToAddress(currencyId); - address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currency); - require(liquidityPool != address(0), "InvestmentManager/tranche-does-not-exist"); - - LiquidityPoolLike(liquidityPool).updatePrice(price); - } - function handleExecutedCollectInvest( uint64 poolId, bytes16 trancheId, - address recipient, - uint128 currency, + address user, + uint128 currencyId, uint128 currencyPayout, - uint128 trancheTokensPayout, + uint128 trancheTokenPayout, uint128 remainingInvestOrder ) public onlyGateway { - require(currencyPayout != 0, "InvestmentManager/zero-invest"); - address _currency = poolManager.currencyIdToAddress(currency); - address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, _currency); - require(liquidityPool != address(0), "InvestmentManager/tranche-does-not-exist"); - - LPValues storage lpValues = orderbook[liquidityPool][recipient]; - lpValues.depositPrice = _calculateNewDepositPrice( - liquidityPool, _maxDeposit(liquidityPool, recipient), lpValues.maxMint, currencyPayout, trancheTokensPayout - ); + address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currencyId); - lpValues.maxMint = lpValues.maxMint + trancheTokensPayout; - lpValues.remainingInvestOrder = remainingInvestOrder; + InvestmentState storage state = investments[liquidityPool][user]; + state.depositPrice = _calculatePrice( + liquidityPool, _maxDeposit(liquidityPool, user) + currencyPayout, state.maxMint + trancheTokenPayout + ); + state.maxMint = state.maxMint + trancheTokenPayout; + state.pendingDepositRequest = remainingInvestOrder; // Mint to escrow. Recipient can claim by calling withdraw / redeem ERC20Like trancheToken = ERC20Like(LiquidityPoolLike(liquidityPool).share()); - trancheToken.mint(address(escrow), trancheTokensPayout); - - LiquidityPoolLike(liquidityPool).updatePrice(_toUint128(lpValues.depositPrice)); + trancheToken.mint(address(escrow), trancheTokenPayout); - emit ExecutedCollectInvest(poolId, trancheId, recipient, currency, currencyPayout, trancheTokensPayout); + LiquidityPoolLike(liquidityPool).emitDepositClaimable(user, currencyPayout, trancheTokenPayout); } function handleExecutedCollectRedeem( uint64 poolId, bytes16 trancheId, - address recipient, - uint128 currency, + address user, + uint128 currencyId, uint128 currencyPayout, - uint128 trancheTokensPayout, + uint128 trancheTokenPayout, uint128 remainingRedeemOrder ) public onlyGateway { - require(trancheTokensPayout != 0, "InvestmentManager/zero-redeem"); - address _currency = poolManager.currencyIdToAddress(currency); - address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, _currency); - require(liquidityPool != address(0), "InvestmentManager/tranche-does-not-exist"); + address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currencyId); + + InvestmentState storage state = investments[liquidityPool][user]; + require(state.exists == true, "InvestmentManager/non-existent-user"); - LPValues storage lpValues = orderbook[liquidityPool][recipient]; - lpValues.redeemPrice = _calculateNewRedeemPrice( + // Calculate new weighted average redeem price and update order book values + state.redeemPrice = _calculatePrice( liquidityPool, - maxRedeem(liquidityPool, recipient), - lpValues.maxWithdraw, - currencyPayout, - trancheTokensPayout + state.maxWithdraw + currencyPayout, + ((maxRedeem(liquidityPool, user)) + trancheTokenPayout).toUint128() ); - lpValues.maxWithdraw = lpValues.maxWithdraw + currencyPayout; - lpValues.remainingRedeemOrder = remainingRedeemOrder; + state.maxWithdraw = state.maxWithdraw + currencyPayout; + state.pendingRedeemRequest = remainingRedeemOrder; // Transfer currency to user escrow to claim on withdraw/redeem, // and burn redeemed tranche tokens from escrow - userEscrow.transferIn(_currency, address(escrow), recipient, currencyPayout); + userEscrow.transferIn(poolManager.currencyIdToAddress(currencyId), address(escrow), user, currencyPayout); ERC20Like trancheToken = ERC20Like(LiquidityPoolLike(liquidityPool).share()); - trancheToken.burn(address(escrow), trancheTokensPayout); - - LiquidityPoolLike(liquidityPool).updatePrice(_toUint128(lpValues.redeemPrice)); + trancheToken.burn(address(escrow), trancheTokenPayout); - emit ExecutedCollectRedeem(poolId, trancheId, recipient, currency, currencyPayout, trancheTokensPayout); + LiquidityPoolLike(liquidityPool).emitRedeemClaimable(user, currencyPayout, trancheTokenPayout); } function handleExecutedDecreaseInvestOrder( uint64 poolId, bytes16 trancheId, address user, - uint128 currency, + uint128 currencyId, uint128 currencyPayout, uint128 remainingInvestOrder ) public onlyGateway { - require(currencyPayout != 0, "InvestmentManager/zero-payout"); + address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currencyId); - address _currency = poolManager.currencyIdToAddress(currency); - address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, _currency); - require(liquidityPool != address(0), "InvestmentManager/tranche-does-not-exist"); - require(_currency == LiquidityPoolLike(liquidityPool).asset(), "InvestmentManager/not-tranche-currency"); - - // Transfer currency amount to userEscrow - userEscrow.transferIn(_currency, address(escrow), user, currencyPayout); + InvestmentState storage state = investments[liquidityPool][user]; + require(state.exists == true, "InvestmentManager/non-existent-user"); // Calculating the price with both payouts as currencyPayout // leads to an effective redeem price of 1.0 and thus the user actually receiving // exactly currencyPayout on both deposit() and mint() - LPValues storage lpValues = orderbook[liquidityPool][user]; - lpValues.redeemPrice = _calculateNewRedeemPrice( - liquidityPool, maxRedeem(liquidityPool, user), lpValues.maxWithdraw, currencyPayout, currencyPayout + (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); + uint256 currencyPayoutInPriceDecimals = _toPriceDecimals(currencyPayout, currencyDecimals); + state.redeemPrice = _calculatePrice( + _toPriceDecimals(state.maxWithdraw, currencyDecimals) + currencyPayoutInPriceDecimals, + _toPriceDecimals(maxRedeem(liquidityPool, user).toUint128(), trancheTokenDecimals) + + currencyPayoutInPriceDecimals ); - lpValues.maxWithdraw = lpValues.maxWithdraw + currencyPayout; - lpValues.remainingInvestOrder = remainingInvestOrder; - emit ExecutedDecreaseInvestOrder(poolId, trancheId, user, currency, currencyPayout); + state.maxWithdraw = state.maxWithdraw + currencyPayout; + state.pendingDepositRequest = remainingInvestOrder; + + // Transfer currency amount to userEscrow + userEscrow.transferIn(poolManager.currencyIdToAddress(currencyId), address(escrow), user, currencyPayout); + + LiquidityPoolLike(liquidityPool).emitRedeemClaimable(user, currencyPayout, currencyPayout); } /// @dev Compared to handleExecutedDecreaseInvestOrder, there is no @@ -426,235 +367,172 @@ contract InvestmentManager is Auth { uint64 poolId, bytes16 trancheId, address user, - uint128 currency, - uint128 trancheTokensPayout, + uint128 currencyId, + uint128 trancheTokenPayout, uint128 remainingRedeemOrder ) public onlyGateway { - require(trancheTokensPayout != 0, "InvestmentManager/zero-payout"); + address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currencyId); + InvestmentState storage state = investments[liquidityPool][user]; - address _currency = poolManager.currencyIdToAddress(currency); - address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, _currency); - require(address(liquidityPool) != address(0), "InvestmentManager/tranche-does-not-exist"); - - // Calculating the price with both payouts as trancheTokensPayout + // Calculating the price with both payouts as trancheTokenPayout // leads to an effective redeem price of 1.0 and thus the user actually receiving - // exactly trancheTokensPayout on both deposit() and mint() - LPValues storage lpValues = orderbook[liquidityPool][user]; - lpValues.depositPrice = _calculateNewDepositPrice( - liquidityPool, _maxDeposit(liquidityPool, user), lpValues.maxMint, trancheTokensPayout, trancheTokensPayout + // exactly trancheTokenPayout on both deposit() and mint() + (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); + uint256 trancheTokenPayoutInPriceDecimals = _toPriceDecimals(trancheTokenPayout, trancheTokenDecimals); + state.depositPrice = _calculatePrice( + _toPriceDecimals(_maxDeposit(liquidityPool, user), currencyDecimals).toUint128() + + trancheTokenPayoutInPriceDecimals, + _toPriceDecimals(state.maxMint, trancheTokenDecimals) + trancheTokenPayoutInPriceDecimals ); - lpValues.maxMint = lpValues.maxMint + trancheTokensPayout; - lpValues.remainingRedeemOrder = remainingRedeemOrder; - emit ExecutedDecreaseRedeemOrder(poolId, trancheId, user, currency, trancheTokensPayout); + state.maxMint = state.maxMint + trancheTokenPayout; + state.pendingRedeemRequest = remainingRedeemOrder; + + LiquidityPoolLike(liquidityPool).emitRedeemClaimable(user, trancheTokenPayout, trancheTokenPayout); } function handleTriggerIncreaseRedeemOrder( uint64 poolId, bytes16 trancheId, address user, - uint128 currency, + uint128 currencyId, uint128 trancheTokenAmount ) public onlyGateway { - address token = poolManager.getTrancheToken(poolId, trancheId); + require(trancheTokenAmount != 0, "InvestmentManager/tranche-token-amount-is-zero"); + address liquidityPool = poolManager.getLiquidityPool(poolId, trancheId, currencyId); + + // If there's any unclaimed deposits, claim those first + InvestmentState storage state = investments[liquidityPool][user]; + uint128 tokensToTransfer = trancheTokenAmount; + if (state.maxMint >= trancheTokenAmount) { + // The full redeem request is covered by the claimable amount + tokensToTransfer = 0; + state.maxMint = state.maxMint - trancheTokenAmount; + } else if (state.maxMint > 0) { + // The redeem request is only partially covered by the claimable amount + tokensToTransfer = trancheTokenAmount - state.maxMint; + state.maxMint = 0; + } - // Transfer the tranche token amount from user to escrow (lock tranche tokens in escrow) require( - AuthTransferLike(token).authTransferFrom(user, address(escrow), trancheTokenAmount), - "InvestmentManager/transfer-failed" + _processRedeemRequest(liquidityPool, trancheTokenAmount, user), "InvestmentManager/failed-redeem-request" ); - gateway.increaseRedeemOrder(poolId, trancheId, user, currency, trancheTokenAmount); - emit TriggerIncreaseRedeemOrder(poolId, trancheId, user, currency, trancheTokenAmount); + // Transfer the tranche token amount that was not covered by tokens still in escrow for claims, + // from user to escrow (lock tranche tokens in escrow) + if (tokensToTransfer > 0) { + require( + AuthTransferLike(address(LiquidityPoolLike(liquidityPool).share())).authTransferFrom( + user, address(escrow), tokensToTransfer + ), + "InvestmentManager/transfer-failed" + ); + } + emit TriggerIncreaseRedeemOrder( + poolId, trancheId, user, poolManager.currencyIdToAddress(currencyId), trancheTokenAmount + ); } // --- View functions --- - function totalAssets(address liquidityPool, uint256 totalSupply) public view returns (uint256 _totalAssets) { - _totalAssets = convertToAssets(liquidityPool, totalSupply); - } - - /// @dev Calculates the amount of shares / tranche tokens that any user would get - /// for the amount of currency / assets provided. - /// The calculation is based on the tranche token price from the most recent epoch retrieved from Centrifuge. function convertToShares(address liquidityPool, uint256 _assets) public view returns (uint256 shares) { - uint128 latestPrice = LiquidityPoolLike(liquidityPool).latestPrice(); - if (latestPrice == 0) { - // If the price is not set, we assume it is 1.00 - latestPrice = uint128(1 * 10 ** PRICE_DECIMALS); - } - - (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); - uint128 assets = _toUint128(_assets); - - shares = assets.mulDiv( - 10 ** (PRICE_DECIMALS + trancheTokenDecimals - currencyDecimals), latestPrice, MathLib.Rounding.Down + LiquidityPoolLike liquidityPool_ = LiquidityPoolLike(liquidityPool); + (uint256 latestPrice,) = poolManager.getTrancheTokenPrice( + liquidityPool_.poolId(), liquidityPool_.trancheId(), liquidityPool_.asset() ); + shares = uint256(_calculateTrancheTokenAmount(_assets.toUint128(), liquidityPool, latestPrice)); } - /// @dev Calculates the asset value for an amount of shares / tranche tokens provided. - /// The calculation is based on the tranche token price from the most recent epoch retrieved from Centrifuge. function convertToAssets(address liquidityPool, uint256 _shares) public view returns (uint256 assets) { - uint128 latestPrice = LiquidityPoolLike(liquidityPool).latestPrice(); - if (latestPrice == 0) { - // If the price is not set, we assume it is 1.00 - latestPrice = uint128(1 * 10 ** PRICE_DECIMALS); - } - - (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); - uint128 shares = _toUint128(_shares); - - assets = shares.mulDiv( - latestPrice, 10 ** (PRICE_DECIMALS + trancheTokenDecimals - currencyDecimals), MathLib.Rounding.Down + LiquidityPoolLike liquidityPool_ = LiquidityPoolLike(liquidityPool); + (uint256 latestPrice,) = poolManager.getTrancheTokenPrice( + liquidityPool_.poolId(), liquidityPool_.trancheId(), liquidityPool_.asset() ); + assets = uint256(_calculateCurrencyAmount(_shares.toUint128(), liquidityPool, latestPrice)); } - /// @return currencyAmount is type of uint256 to support the EIP4626 Liquidity Pool interface function maxDeposit(address liquidityPool, address user) public view returns (uint256) { if (!_checkTransferRestriction(liquidityPool, address(escrow), user, 0)) return 0; - return _maxDeposit(liquidityPool, user); + return uint256(_maxDeposit(liquidityPool, user)); } - function _maxDeposit(address liquidityPool, address user) internal view returns (uint256) { - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.maxMint == 0 || lpValues.depositPrice == 0) return 0; - return uint256(_calculateCurrencyAmount(lpValues.maxMint, liquidityPool, lpValues.depositPrice)); + function _maxDeposit(address liquidityPool, address user) internal view returns (uint128) { + InvestmentState memory state = investments[liquidityPool][user]; + return _calculateCurrencyAmount(state.maxMint, liquidityPool, state.depositPrice); } - /// @return trancheTokenAmount type of uint256 to support the EIP4626 Liquidity Pool interface function maxMint(address liquidityPool, address user) public view returns (uint256 trancheTokenAmount) { if (!_checkTransferRestriction(liquidityPool, address(escrow), user, 0)) return 0; - return uint256(orderbook[liquidityPool][user].maxMint); + return uint256(investments[liquidityPool][user].maxMint); } - /// @return currencyAmount type of uint256 to support the EIP4626 Liquidity Pool interface function maxWithdraw(address liquidityPool, address user) public view returns (uint256 currencyAmount) { - return uint256(orderbook[liquidityPool][user].maxWithdraw); + return uint256(investments[liquidityPool][user].maxWithdraw); } - /// @return trancheTokenAmount type of uint256 to support the EIP4626 Liquidity Pool interface function maxRedeem(address liquidityPool, address user) public view returns (uint256 trancheTokenAmount) { - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.maxWithdraw == 0 || lpValues.redeemPrice == 0) return 0; - return uint256(_calculateTrancheTokenAmount(lpValues.maxWithdraw, liquidityPool, lpValues.redeemPrice)); - } - - /// @return trancheTokenAmount is type of uint256 to support the EIP4626 Liquidity Pool interface - function previewDeposit(address liquidityPool, address user, uint256 _currencyAmount) - public - view - returns (uint256 trancheTokenAmount) - { - uint128 currencyAmount = _toUint128(_currencyAmount); - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.depositPrice == 0) return 0; - - trancheTokenAmount = uint256(_calculateTrancheTokenAmount(currencyAmount, liquidityPool, lpValues.depositPrice)); + InvestmentState memory state = investments[liquidityPool][user]; + return uint256(_calculateTrancheTokenAmount(state.maxWithdraw, liquidityPool, state.redeemPrice)); } - /// @return currencyAmount is type of uint256 to support the EIP4626 Liquidity Pool interface - function previewMint(address liquidityPool, address user, uint256 _trancheTokenAmount) - public - view - returns (uint256 currencyAmount) - { - uint128 trancheTokenAmount = _toUint128(_trancheTokenAmount); - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.depositPrice == 0) return 0; - - currencyAmount = uint256(_calculateCurrencyAmount(trancheTokenAmount, liquidityPool, lpValues.depositPrice)); + function pendingDepositRequest(address liquidityPool, address user) public view returns (uint256 currencyAmount) { + currencyAmount = uint256(investments[liquidityPool][user].pendingDepositRequest); } - /// @return trancheTokenAmount is type of uint256 to support the EIP4626 Liquidity Pool interface - function previewWithdraw(address liquidityPool, address user, uint256 _currencyAmount) + function pendingRedeemRequest(address liquidityPool, address user) public view returns (uint256 trancheTokenAmount) { - uint128 currencyAmount = _toUint128(_currencyAmount); - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.redeemPrice == 0) return 0; - - trancheTokenAmount = uint256(_calculateTrancheTokenAmount(currencyAmount, liquidityPool, lpValues.redeemPrice)); - } - - /// @return currencyAmount is type of uint256 to support the EIP4626 Liquidity Pool interface - function previewRedeem(address liquidityPool, address user, uint256 _trancheTokenAmount) - public - view - returns (uint256 currencyAmount) - { - uint128 trancheTokenAmount = _toUint128(_trancheTokenAmount); - LPValues memory lpValues = orderbook[liquidityPool][user]; - if (lpValues.redeemPrice == 0) return 0; - - currencyAmount = uint256(_calculateCurrencyAmount(trancheTokenAmount, liquidityPool, lpValues.redeemPrice)); - } - - function userDepositRequest(address liquidityPool, address user) public view returns (uint256 currencyAmount) { - currencyAmount = uint256(orderbook[liquidityPool][user].remainingInvestOrder); + trancheTokenAmount = uint256(investments[liquidityPool][user].pendingRedeemRequest); } - function userRedeemRequest(address liquidityPool, address user) public view returns (uint256 trancheTokenAmount) { - trancheTokenAmount = uint256(orderbook[liquidityPool][user].remainingRedeemOrder); + function exchangeRateLastUpdated(address liquidityPool) public view returns (uint64 lastUpdated) { + LiquidityPoolLike liquidityPool_ = LiquidityPoolLike(liquidityPool); + (, lastUpdated) = poolManager.getTrancheTokenPrice( + liquidityPool_.poolId(), liquidityPool_.trancheId(), liquidityPool_.asset() + ); } // --- Liquidity Pool processing functions --- /// @notice Processes owner's currency deposit / investment after the epoch has been executed on Centrifuge. - /// In case owner's invest order was fulfilled (partially or in full) on Centrifuge during epoch execution - /// MaxDeposit and MaxMint are increased and tranche tokens can be transferred to user's wallet on - /// calling processDeposit. The currency required to fulfill the invest order is already - /// locked in escrow upon calling requestDeposit. - /// @dev trancheTokenAmount return value is type of uint256 to be compliant with EIP4626 LiquidityPool interface - /// @return trancheTokenAmount the amount of tranche tokens transferred to the user's wallet after - /// successful deposit. - function processDeposit(address liquidityPool, uint256 currencyAmount, address receiver, address owner) + /// The currency required to fulfill the invest order is already locked in escrow upon calling + /// requestDeposit. + function deposit(address liquidityPool, uint256 currencyAmount, address receiver, address owner) public auth returns (uint256 trancheTokenAmount) { - LPValues storage lpValues = orderbook[liquidityPool][owner]; - uint128 _trancheTokenAmount = - _calculateTrancheTokenAmount(_toUint128(currencyAmount), liquidityPool, lpValues.depositPrice); - - require(_trancheTokenAmount != 0, "InvestmentManager/tranche-token-amount-is-zero"); - - _deposit(lpValues, _trancheTokenAmount, liquidityPool, receiver); - trancheTokenAmount = uint256(_trancheTokenAmount); + InvestmentState storage state = investments[liquidityPool][owner]; + uint128 trancheTokenAmount_ = + _calculateTrancheTokenAmount(currencyAmount.toUint128(), liquidityPool, state.depositPrice); + _processDeposit(state, trancheTokenAmount_, liquidityPool, receiver); + trancheTokenAmount = uint256(trancheTokenAmount_); } /// @notice Processes owner's currency deposit / investment after the epoch has been executed on Centrifuge. - /// In case owner's invest order was fulfilled on Centrifuge during epoch execution maxDeposit - /// and maxMint are increased and trancheTokens can be transferred to owner's wallet on calling - /// processDeposit or processMint. The currency amount required to fulfill the invest order is - /// already locked in escrow upon calling requestDeposit. The tranche tokens are already minted - /// on collectInvest and are deposited to the escrow account until the owner calls mint, or deposit. - /// The tranche tokens are transferred to the receivers wallet. - /// @dev currencyAmount return value is type of uint256 to be compliant with EIP4626 LiquidityPool interface - /// @return currencyAmount the amount of liquidityPool assets invested and locked in escrow in order - /// for the amount of tranche tokens received after successful investment into the pool. - function processMint(address liquidityPool, uint256 trancheTokenAmount, address receiver, address owner) + /// The currency required to fulfill the invest order is already locked in escrow upon calling + /// requestDeposit. + function mint(address liquidityPool, uint256 trancheTokenAmount, address receiver, address owner) public auth returns (uint256 currencyAmount) { - uint128 _trancheTokenAmount = _toUint128(trancheTokenAmount); - LPValues storage lpValues = orderbook[liquidityPool][owner]; - - _deposit(lpValues, _trancheTokenAmount, liquidityPool, receiver); - uint128 _currencyAmount = _calculateCurrencyAmount(_trancheTokenAmount, liquidityPool, lpValues.depositPrice); - currencyAmount = uint256(_currencyAmount); + InvestmentState storage state = investments[liquidityPool][owner]; + _processDeposit(state, trancheTokenAmount.toUint128(), liquidityPool, receiver); + currencyAmount = + uint256(_calculateCurrencyAmount(trancheTokenAmount.toUint128(), liquidityPool, state.depositPrice)); } - function _deposit(LPValues storage lpValues, uint128 trancheTokenAmount, address liquidityPool, address receiver) - internal - { + function _processDeposit( + InvestmentState storage state, + uint128 trancheTokenAmount, + address liquidityPool, + address receiver + ) internal { LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); - require(trancheTokenAmount <= lpValues.maxMint, "InvestmentManager/exceeds-deposit-limits"); - - // Decrease the deposit limits - lpValues.maxMint = lpValues.maxMint - trancheTokenAmount; - - // Transfer the tranche tokens to the user + require(trancheTokenAmount != 0, "InvestmentManager/tranche-token-amount-is-zero"); + require(trancheTokenAmount <= state.maxMint, "InvestmentManager/exceeds-deposit-limits"); + state.maxMint = state.maxMint - trancheTokenAmount; require( lPool.transferFrom(address(escrow), receiver, trancheTokenAmount), "InvestmentManager/tranche-tokens-transfer-failed" @@ -662,59 +540,45 @@ contract InvestmentManager is Auth { } /// @dev Processes owner's tranche Token redemption after the epoch has been executed on Centrifuge. - /// In case owner's redemption order was fulfilled on Centrifuge during epoch execution maxRedeem - /// and maxWithdraw are increased and LiquidityPool currency can be transferred to owner's wallet - /// on calling processRedeem or processWithdraw. The trancheTokenAmount required to fulfill the - /// redemption order was already locked in escrow upon calling requestRedeem and burned upon collectRedeem. - /// @notice currencyAmount return value is type of uint256 to be compliant with EIP4626 LiquidityPool interface - /// @return currencyAmount the amount of liquidityPool assets received for the amount of redeemed/burned tokens. - function processRedeem(address liquidityPool, uint256 trancheTokenAmount, address receiver, address owner) + /// The trancheTokenAmount required to fulfill the redemption order was already locked in escrow + /// upon calling requestRedeem. + function redeem(address liquidityPool, uint256 trancheTokenAmount, address receiver, address owner) public auth returns (uint256 currencyAmount) { - LPValues storage lpValues = orderbook[liquidityPool][owner]; - uint128 _currencyAmount = - _calculateCurrencyAmount(_toUint128(trancheTokenAmount), liquidityPool, lpValues.redeemPrice); - - _redeem(lpValues, _currencyAmount, liquidityPool, receiver, owner); - currencyAmount = uint256(_currencyAmount); + InvestmentState storage state = investments[liquidityPool][owner]; + uint128 currencyAmount_ = + _calculateCurrencyAmount(trancheTokenAmount.toUint128(), liquidityPool, state.redeemPrice); + _processRedeem(state, currencyAmount_, liquidityPool, receiver, owner); + currencyAmount = uint256(currencyAmount_); } /// @dev Processes owner's tranche token redemption after the epoch has been executed on Centrifuge. - /// In case owner's redemption order was fulfilled on Centrifuge during epoch execution maxRedeem - /// and maxWithdraw are increased and LiquidityPool currency can be transferred to owner's wallet - /// on calling processRedeem or processWithdraw. The trancheTokenAmount required to fulfill the - /// redemption order was already locked in escrow upon calling requestRedeem and burned upon collectRedeem. - /// @notice trancheTokenAmount return value is type of uint256 to be compliant with EIP4626 LiquidityPool interface - /// @return trancheTokenAmount the amount of trancheTokens redeemed/burned required to receive - /// the currencyAmount payout/withdrawal. - function processWithdraw(address liquidityPool, uint256 currencyAmount, address receiver, address owner) + /// The trancheTokenAmount required to fulfill the redemption order was already locked in escrow + /// upon calling requestRedeem. + function withdraw(address liquidityPool, uint256 currencyAmount, address receiver, address owner) public auth returns (uint256 trancheTokenAmount) { - uint128 _currencyAmount = _toUint128(currencyAmount); - LPValues storage lpValues = orderbook[liquidityPool][owner]; - require(currencyAmount != 0, "InvestmentManager/currency-amount-is-zero"); - - _redeem(lpValues, _currencyAmount, liquidityPool, receiver, owner); - uint128 _trancheTokenAmount = _calculateTrancheTokenAmount(_currencyAmount, liquidityPool, lpValues.redeemPrice); - trancheTokenAmount = uint256(_trancheTokenAmount); + InvestmentState storage state = investments[liquidityPool][owner]; + _processRedeem(state, currencyAmount.toUint128(), liquidityPool, receiver, owner); + trancheTokenAmount = + uint256(_calculateTrancheTokenAmount(currencyAmount.toUint128(), liquidityPool, state.redeemPrice)); } - function _redeem( - LPValues storage lpValues, + function _processRedeem( + InvestmentState storage state, uint128 currencyAmount, address liquidityPool, address receiver, address owner ) internal { LiquidityPoolLike lPool = LiquidityPoolLike(liquidityPool); - require(currencyAmount <= lpValues.maxWithdraw, "InvestmentManager/exceeds-redeem-limits"); - - // Decrease maxWithdraw - lpValues.maxWithdraw = lpValues.maxWithdraw - currencyAmount; + require(currencyAmount != 0, "InvestmentManager/currency-amount-is-zero"); + require(currencyAmount <= state.maxWithdraw, "InvestmentManager/exceeds-redeem-limits"); + state.maxWithdraw = state.maxWithdraw - currencyAmount; userEscrow.transferOut(lPool.asset(), owner, receiver, currencyAmount); } @@ -755,58 +619,43 @@ contract InvestmentManager is Auth { } } - function _calculateNewDepositPrice( - address liquidityPool, - uint256 currentMaxDeposit, - uint128 currentMaxMint, - uint128 currencyPayout, - uint128 trancheTokensPayout - ) internal view returns (uint256 depositPrice) { - (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); - - uint256 newMaxDeposit = currentMaxDeposit + _toPriceDecimals(currencyPayout, currencyDecimals); - uint256 newMaxMint = _toPriceDecimals(currentMaxMint + trancheTokensPayout, trancheTokenDecimals); - if (newMaxMint == 0) depositPrice = 0; - else depositPrice = newMaxDeposit.mulDiv(10 ** PRICE_DECIMALS, newMaxMint, MathLib.Rounding.Down); - } - - function _calculateNewRedeemPrice( - address liquidityPool, - uint256 currentMaxRedeem, - uint128 currentMaxWithdraw, - uint128 currencyPayout, - uint128 trancheTokensPayout - ) internal view returns (uint256 redeemPrice) { + function _calculatePrice(address liquidityPool, uint128 currencyAmount, uint128 trancheTokenAmount) + internal + view + returns (uint256 price) + { (uint8 currencyDecimals, uint8 trancheTokenDecimals) = _getPoolDecimals(liquidityPool); - - uint256 newMaxRedeem = currentMaxRedeem + _toPriceDecimals(trancheTokensPayout, trancheTokenDecimals); - uint256 newMaxWithdraw = _toPriceDecimals(currentMaxWithdraw + currencyPayout, currencyDecimals); - if (newMaxWithdraw == 0) redeemPrice = 0; - else redeemPrice = newMaxWithdraw.mulDiv(10 ** PRICE_DECIMALS, newMaxRedeem, MathLib.Rounding.Down); + price = _calculatePrice( + _toPriceDecimals(currencyAmount, currencyDecimals), + _toPriceDecimals(trancheTokenAmount, trancheTokenDecimals) + ); } - /// @dev Safe type conversion from uint256 to uint128. Revert if value is too big to be stored - /// with uint128. Avoid data loss. - /// @return value - safely converted without data loss - function _toUint128(uint256 _value) internal pure returns (uint128 value) { - if (_value > type(uint128).max) { - revert("InvestmentManager/uint128-overflow"); - } else { - value = uint128(_value); + function _calculatePrice(uint256 currencyAmountInPriceDecimals, uint256 trancheTokenAmountInPriceDecimals) + internal + pure + returns (uint256 price) + { + if (currencyAmountInPriceDecimals == 0 || trancheTokenAmountInPriceDecimals == 0) { + return 0; } + + price = currencyAmountInPriceDecimals.mulDiv( + 10 ** PRICE_DECIMALS, trancheTokenAmountInPriceDecimals, MathLib.Rounding.Down + ); } - /// @dev When converting currency to tranche token amounts using the price, - /// all values are normalized to PRICE_DECIMALS + /// @dev When converting currency to tranche token amounts using the price, + /// all values are normalized to PRICE_DECIMALS function _toPriceDecimals(uint128 _value, uint8 decimals) internal pure returns (uint256 value) { if (PRICE_DECIMALS == decimals) return uint256(_value); value = uint256(_value) * 10 ** (PRICE_DECIMALS - decimals); } - /// @dev Convert decimals of the value from the price decimals back to the intended decimals + /// @dev Convert decimals of the value from the price decimals back to the intended decimals function _fromPriceDecimals(uint256 _value, uint8 decimals) internal pure returns (uint128 value) { - if (PRICE_DECIMALS == decimals) return _toUint128(_value); - value = _toUint128(_value / 10 ** (PRICE_DECIMALS - decimals)); + if (PRICE_DECIMALS == decimals) return _value.toUint128(); + value = (_value / 10 ** (PRICE_DECIMALS - decimals)).toUint128(); } /// @dev Return the currency decimals and the tranche token decimals for a given liquidityPool diff --git a/src/LiquidityPool.sol b/src/LiquidityPool.sol index e39b787c..c2cbc7b3 100644 --- a/src/LiquidityPool.sol +++ b/src/LiquidityPool.sol @@ -3,363 +3,327 @@ pragma solidity 0.8.21; import {Auth} from "./util/Auth.sol"; import {MathLib} from "./util/MathLib.sol"; -import {IERC20} from "./interfaces/IERC20.sol"; +import {SafeTransferLib} from "./util/SafeTransferLib.sol"; import {IERC4626} from "./interfaces/IERC4626.sol"; - -interface ERC20PermitLike { - function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - external; - function PERMIT_TYPEHASH() external view returns (bytes32); - function DOMAIN_SEPARATOR() external view returns (bytes32); -} - -interface TrancheTokenLike is IERC20, ERC20PermitLike { - function mint(address user, uint256 value) external; - function burn(address user, uint256 value) external; -} - -interface InvestmentManagerLike { - function processDeposit(address liquidityPool, uint256 assets, address receiver, address owner) - external - returns (uint256); - function processMint(address liquidityPool, uint256 shares, address receiver, address owner) - external - returns (uint256); - function processWithdraw(address liquidityPool, uint256 assets, address receiver, address owner) - external - returns (uint256); - function processRedeem(address liquidityPool, uint256 shares, address receiver, address owner) - external - returns (uint256); - function maxDeposit(address liquidityPool, address user) external view returns (uint256); - function maxMint(address liquidityPool, address user) external view returns (uint256); - function maxWithdraw(address liquidityPool, address user) external view returns (uint256); - function maxRedeem(address liquidityPool, address user) external view returns (uint256); - function totalAssets(address liquidityPool, uint256 totalSupply) external view returns (uint256); - function convertToShares(address liquidityPool, uint256 assets) external view returns (uint256); - function convertToAssets(address liquidityPool, uint256 shares) external view returns (uint256); - function previewDeposit(address liquidityPool, address user, uint256 assets) external view returns (uint256); - function previewMint(address liquidityPool, address user, uint256 shares) external view returns (uint256); - function previewWithdraw(address liquidityPool, address user, uint256 assets) external view returns (uint256); - function previewRedeem(address liquidityPool, address user, uint256 shares) external view returns (uint256); - function requestRedeem(address liquidityPool, uint256 shares, address receiver) external; - function requestDeposit(address liquidityPool, uint256 assets, address receiver) external; - function decreaseDepositRequest(address liquidityPool, uint256 assets, address receiver) external; - function decreaseRedeemRequest(address liquidityPool, uint256 shares, address receiver) external; - function cancelDepositRequest(address liquidityPool, address receiver) external; - function cancelRedeemRequest(address liquidityPool, address receiver) external; - function userDepositRequest(address liquidityPool, address user) external view returns (uint256); - function userRedeemRequest(address liquidityPool, address user) external view returns (uint256); +import {IERC20, IERC20Metadata, IERC20Permit} from "./interfaces/IERC20.sol"; +import {IERC7540, IERC165, IERC7540Deposit, IERC7540Redeem} from "./interfaces/IERC7540.sol"; + +interface ManagerLike { + function deposit(address lp, uint256 assets, address receiver, address owner) external returns (uint256); + function mint(address lp, uint256 shares, address receiver, address owner) external returns (uint256); + function withdraw(address lp, uint256 assets, address receiver, address owner) external returns (uint256); + function redeem(address lp, uint256 shares, address receiver, address owner) external returns (uint256); + function maxDeposit(address lp, address receiver) external view returns (uint256); + function maxMint(address lp, address receiver) external view returns (uint256); + function maxWithdraw(address lp, address receiver) external view returns (uint256); + function maxRedeem(address lp, address receiver) external view returns (uint256); + function convertToShares(address lp, uint256 assets) external view returns (uint256); + function convertToAssets(address lp, uint256 shares) external view returns (uint256); + function requestDeposit(address lp, uint256 assets, address sender, address operator) external returns (bool); + function requestRedeem(address lp, uint256 shares, address operator, address owner) external returns (bool); + function decreaseDepositRequest(address lp, uint256 assets, address operator) external; + function decreaseRedeemRequest(address lp, uint256 shares, address operator) external; + function cancelDepositRequest(address lp, address operator) external; + function cancelRedeemRequest(address lp, address operator) external; + function pendingDepositRequest(address lp, address operator) external view returns (uint256); + function pendingRedeemRequest(address lp, address operator) external view returns (uint256); + function exchangeRateLastUpdated(address liquidityPool) external view returns (uint64 lastUpdated); } /// @title Liquidity Pool /// @notice Liquidity Pool implementation for Centrifuge pools -/// following the EIP4626 standard, with asynchronous extension methods. +/// following the ERC-7540 Asynchronous Tokenized Vault standard /// -/// @dev Each Liquidity Pool is a tokenized vault issuing shares of Centrifuge tranches as restricted ERC20 tokens +/// @dev Each Liquidity Pool is a tokenized vault issuing shares of Centrifuge tranches as restricted ERC-20 tokens /// against currency deposits based on the current share price. /// -/// This is extending the EIP4626 standard by 'requestDeposit' & 'requestRedeem' functions, where deposit and -/// redeem orders are submitted to the pools to be included in the execution of the following epoch. After -/// execution users can use the deposit, mint, redeem and withdraw functions to get their shares +/// ERC-7540 is an extension of the ERC-4626 standard by 'requestDeposit' & 'requestRedeem' methods, where +/// deposit and redeem orders are submitted to the pools to be included in the execution of the following epoch. +/// After execution users can use the deposit, mint, redeem and withdraw functions to get their shares /// and/or assets from the pools. -contract LiquidityPool is Auth, IERC4626 { +contract LiquidityPool is Auth, IERC7540 { using MathLib for uint256; + /// @notice Identifier of the Centrifuge pool uint64 public immutable poolId; + + /// @notice Identifier of the tranche of the Centrifuge pool bytes16 public immutable trancheId; - /// @notice The investment currency for this Liquidity Pool. + /// @notice The investment currency (asset) for this Liquidity Pool. /// Each tranche of a Centrifuge pool can have multiple Liquidity Pools. - /// One Liquidity Pool for each supported asset. - /// Thus tranche shares can be linked to multiple LiquidityPools with different assets. - /// @dev Also known as the investment currency. + /// One Liquidity Pool for each supported investment currency. + /// Thus tranche shares can be linked to multiple Liquidity Pools with different currencies. address public immutable asset; - /// @notice The restricted ERC-20 Liquidity Pool token. Has a ratio (token price) of underlying assets - /// exchanged on deposit/withdraw/redeem. - /// @dev Also known as tranche tokens. - TrancheTokenLike public immutable share; + /// @notice The restricted ERC-20 Liquidity Pool share (tranche token). + /// Has a ratio (token price) of underlying assets exchanged on deposit/mint/withdraw/redeem. + IERC20Metadata public immutable share; - InvestmentManagerLike public investmentManager; + /// @notice Liquidity Pool implementation contract + ManagerLike public manager; - /// @notice Tranche token price, denominated in the asset - uint128 public latestPrice; - - /// @notice Timestamp of the last price update - uint256 public lastPriceUpdate; + /// @notice Escrow contract for tokens + address public immutable escrow; // --- Events --- event File(bytes32 indexed what, address data); - event DepositRequest(address indexed owner, uint256 assets); - event RedeemRequest(address indexed owner, uint256 shares); - event DecreaseDepositRequest(address indexed owner, uint256 assets); - event DecreaseRedeemRequest(address indexed owner, uint256 shares); - event CancelDepositRequest(address indexed owner); - event CancelRedeemRequest(address indexed owner); - event PriceUpdate(uint128 price); - - constructor(uint64 poolId_, bytes16 trancheId_, address asset_, address share_, address investmentManager_) { + event DepositClaimable(address indexed operator, uint256 assets, uint256 shares); + event RedeemClaimable(address indexed operator, uint256 assets, uint256 shares); + event DecreaseDepositRequest(address indexed sender, uint256 assets); + event DecreaseRedeemRequest(address indexed sender, uint256 shares); + event CancelDepositRequest(address indexed sender); + event CancelRedeemRequest(address indexed sender); + + constructor(uint64 poolId_, bytes16 trancheId_, address asset_, address share_, address escrow_, address manager_) { poolId = poolId_; trancheId = trancheId_; asset = asset_; - share = TrancheTokenLike(share_); - investmentManager = InvestmentManagerLike(investmentManager_); + share = IERC20Metadata(share_); + escrow = escrow_; + manager = ManagerLike(manager_); wards[msg.sender] = 1; emit Rely(msg.sender); } - /// @dev Owner needs to be the msg.sender - modifier withApproval(address owner) { - require((msg.sender == owner), "LiquidityPool/no-approval"); - _; - } - // --- Administration --- - function file(bytes32 what, address data) public auth { - if (what == "investmentManager") investmentManager = InvestmentManagerLike(data); + function file(bytes32 what, address data) external auth { + if (what == "manager") manager = ManagerLike(data); else revert("LiquidityPool/file-unrecognized-param"); emit File(what, data); } - // --- ERC4626 functions --- - /// @return Total value of the shares, denominated in the asset of this Liquidity Pool - function totalAssets() public view returns (uint256) { - return investmentManager.totalAssets(address(this), totalSupply()); + // --- ERC-7540 methods --- + /// @inheritdoc IERC7540Deposit + function requestDeposit(uint256 assets, address operator) external { + require(IERC20(asset).balanceOf(msg.sender) >= assets, "LiquidityPool/insufficient-balance"); + require( + manager.requestDeposit(address(this), assets, msg.sender, operator), "LiquidityPool/request-deposit-failed" + ); + SafeTransferLib.safeTransferFrom(asset, msg.sender, address(escrow), assets); + emit DepositRequest(msg.sender, operator, assets); } - /// @notice Calculates the amount of shares that any user would approximately get for the amount of assets provided. - /// The calculation is based on the token price from the most recent epoch retrieved from Centrifuge. - /// The actual conversion will likely differ as the price changes between order submission and execution. - function convertToShares(uint256 assets) public view returns (uint256 shares) { - shares = investmentManager.convertToShares(address(this), assets); + /// @notice Uses EIP-2612 permit to set approval of asset, then transfers assets from msg.sender + /// into the Vault and submits a Request for asynchronous deposit/mint. + function requestDepositWithPermit(uint256 assets, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { + try IERC20Permit(asset).permit(msg.sender, address(this), assets, deadline, v, r, s) {} catch {} + require( + manager.requestDeposit(address(this), assets, msg.sender, msg.sender), + "LiquidityPool/request-deposit-failed" + ); + SafeTransferLib.safeTransferFrom(asset, msg.sender, address(escrow), assets); + emit DepositRequest(msg.sender, msg.sender, assets); } - /// @notice Calculates the asset value for an amount of shares provided. - /// The calculation is based on the token price from the most recent epoch retrieved from Centrifuge. - /// The actual conversion will likely differ as the price changes between order submission and execution. - function convertToAssets(uint256 shares) public view returns (uint256 assets) { - assets = investmentManager.convertToAssets(address(this), shares); + /// @inheritdoc IERC7540Deposit + function pendingDepositRequest(address operator) external view returns (uint256 assets) { + assets = manager.pendingDepositRequest(address(this), operator); + } + + /// @inheritdoc IERC7540Redeem + function requestRedeem(uint256 shares, address operator, address owner) external { + require(share.balanceOf(owner) >= shares, "LiquidityPool/insufficient-balance"); + require(manager.requestRedeem(address(this), shares, operator, owner), "LiquidityPool/request-redeem-failed"); + require(transferFrom(owner, address(escrow), shares), "LiquidityPool/transfer-failed"); + emit RedeemRequest(msg.sender, operator, owner, shares); + } + + /// @inheritdoc IERC7540Redeem + function pendingRedeemRequest(address operator) external view returns (uint256 shares) { + shares = manager.pendingRedeemRequest(address(this), operator); } - /// @return maxAssets that can be deposited into the Tranche by the receiver - /// after the epoch had been executed on Centrifuge. - function maxDeposit(address receiver) public view returns (uint256 maxAssets) { - maxAssets = investmentManager.maxDeposit(address(this), receiver); + // --- Misc asynchronous vault methods --- + /// @notice Request decreasing the outstanding deposit orders. + function decreaseDepositRequest(uint256 assets) external { + manager.decreaseDepositRequest(address(this), assets, msg.sender); + emit DecreaseDepositRequest(msg.sender, assets); } - /// @return shares that any user would get for an amount of assets provided - function previewDeposit(uint256 assets) public view returns (uint256 shares) { - shares = investmentManager.previewDeposit(address(this), msg.sender, assets); + /// @notice Request cancelling the outstanding deposit orders. + function cancelDepositRequest() external { + manager.cancelDepositRequest(address(this), msg.sender); + emit CancelDepositRequest(msg.sender); } - /// @notice Collect shares for deposited assets after Centrifuge epoch execution. - /// maxDeposit is the max amount of assets that can be deposited. - function deposit(uint256 assets, address receiver) public returns (uint256 shares) { - shares = investmentManager.processDeposit(address(this), assets, receiver, msg.sender); - emit Deposit(address(this), receiver, assets, shares); + /// @notice Request decreasing the outstanding redemption orders. + function decreaseRedeemRequest(uint256 shares) external { + manager.decreaseRedeemRequest(address(this), shares, msg.sender); + emit DecreaseRedeemRequest(msg.sender, shares); } - /// @notice Collect shares for deposited assets after Centrifuge epoch execution. - /// maxMint is the max amount of shares that can be minted. - function mint(uint256 shares, address receiver) public returns (uint256 assets) { - assets = investmentManager.processMint(address(this), shares, receiver, msg.sender); - emit Deposit(address(this), receiver, assets, shares); + /// @notice Request cancelling the outstanding redemption orders. + function cancelRedeemRequest() external { + manager.cancelRedeemRequest(address(this), msg.sender); + emit CancelRedeemRequest(msg.sender); } - /// @notice maxShares that can be claimed by the receiver after the epoch has been executed on the Centrifuge side. - function maxMint(address receiver) external view returns (uint256 maxShares) { - maxShares = investmentManager.maxMint(address(this), receiver); + function exchangeRateLastUpdated() external view returns (uint64) { + return manager.exchangeRateLastUpdated(address(this)); } - /// @return assets that any user would get for an amount of shares provided -> convertToAssets - function previewMint(uint256 shares) external view returns (uint256 assets) { - assets = investmentManager.previewMint(address(this), msg.sender, shares); + // --- ERC165 support --- + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC7540Deposit).interfaceId + || interfaceId == type(IERC7540Redeem).interfaceId; } - /// @return maxAssets that the receiver can withdraw - function maxWithdraw(address receiver) public view returns (uint256 maxAssets) { - maxAssets = investmentManager.maxWithdraw(address(this), receiver); + // --- ERC-4626 methods --- + /// @inheritdoc IERC4626 + function totalAssets() external view returns (uint256) { + return convertToAssets(totalSupply()); } - /// @return shares that a user would need to redeem in order to receive - /// the given amount of assets -> convertToAssets - function previewWithdraw(uint256 assets) public view returns (uint256 shares) { - shares = investmentManager.previewWithdraw(address(this), msg.sender, assets); + /// @inheritdoc IERC4626 + /// @notice The calculation is based on the token price from the most recent epoch retrieved from Centrifuge. + /// The actual conversion MAY change between order submission and execution. + function convertToShares(uint256 assets) public view returns (uint256 shares) { + shares = manager.convertToShares(address(this), assets); } - /// @notice Withdraw assets after successful epoch execution. Receiver will receive an exact amount of assets for - /// a certain amount of shares that has been redeemed from Owner during epoch execution. - /// @return shares that have been redeemed for the exact assets amount - function withdraw(uint256 assets, address receiver, address owner) - public - withApproval(owner) - returns (uint256 shares) - { - shares = investmentManager.processWithdraw(address(this), assets, receiver, owner); - emit Withdraw(address(this), receiver, owner, assets, shares); + /// @inheritdoc IERC4626 + /// @notice The calculation is based on the token price from the most recent epoch retrieved from Centrifuge. + /// The actual conversion MAY change between order submission and execution. + function convertToAssets(uint256 shares) public view returns (uint256 assets) { + assets = manager.convertToAssets(address(this), shares); } - /// @notice maxShares that can be redeemed by the owner after redemption was requested - function maxRedeem(address owner) public view returns (uint256 maxShares) { - maxShares = investmentManager.maxRedeem(address(this), owner); + /// @inheritdoc IERC4626 + function maxDeposit(address operator) external view returns (uint256 maxAssets) { + maxAssets = manager.maxDeposit(address(this), operator); } - /// @return assets that any user could redeem for a given amount of shares - function previewRedeem(uint256 shares) public view returns (uint256 assets) { - assets = investmentManager.previewRedeem(address(this), msg.sender, shares); + /// @inheritdoc IERC4626 + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + shares = manager.deposit(address(this), assets, receiver, msg.sender); + emit Deposit(msg.sender, receiver, assets, shares); } - /// @notice Redeem shares after successful epoch execution. Receiver will receive assets for - /// @notice Redeem shares can only be called by the Owner or an authorized admin. - /// the exact amount of redeemed shares from Owner after epoch execution. - /// @return assets payout for the exact amount of redeemed shares - function redeem(uint256 shares, address receiver, address owner) - public - withApproval(owner) - returns (uint256 assets) - { - assets = investmentManager.processRedeem(address(this), shares, receiver, owner); - emit Withdraw(address(this), receiver, owner, assets, shares); + /// @inheritdoc IERC4626 + function maxMint(address operator) external view returns (uint256 maxShares) { + maxShares = manager.maxMint(address(this), operator); } - // --- Asynchronous 4626 functions --- - /// @notice Request asset deposit for a receiver to be included in the next epoch execution. - /// @notice Request can only be called by the owner of the assets - /// Asset is locked in the escrow on request submission - function requestDeposit(uint256 assets) public { - investmentManager.requestDeposit(address(this), assets, msg.sender); - emit DepositRequest(msg.sender, assets); + /// @inheritdoc IERC4626 + function mint(uint256 shares, address receiver) external returns (uint256 assets) { + assets = manager.mint(address(this), shares, receiver, msg.sender); + emit Deposit(msg.sender, receiver, assets, shares); } - /// @notice Similar to requestDeposit, but with a permit option - function requestDepositWithPermit(uint256 assets, address owner, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - public - { - _withPermit(asset, owner, address(investmentManager), assets, deadline, v, r, s); - investmentManager.requestDeposit(address(this), assets, owner); - emit DepositRequest(owner, assets); + /// @inheritdoc IERC4626 + function maxWithdraw(address operator) external view returns (uint256 maxAssets) { + maxAssets = manager.maxWithdraw(address(this), operator); } - /// @notice View the total amount the user has requested to deposit but isn't able to deposit or mint yet - function userDepositRequest(address user) external view returns (uint256 assets) { - assets = investmentManager.userDepositRequest(address(this), user); + /// @inheritdoc IERC4626 + /// @notice DOES NOT support operator != msg.sender since shares are already transferred on requestRedeem + function withdraw(uint256 assets, address receiver, address operator) external returns (uint256 shares) { + require((msg.sender == operator), "LiquidityPool/not-the-operator"); + shares = manager.withdraw(address(this), assets, receiver, operator); + emit Withdraw(msg.sender, receiver, operator, assets, shares); } - /// @notice Request decreasing the outstanding deposit orders. Will return the assets once the order - /// on Centrifuge is successfully decreased. - function decreaseDepositRequest(uint256 assets) public { - investmentManager.decreaseDepositRequest(address(this), assets, msg.sender); - emit DecreaseDepositRequest(msg.sender, assets); + /// @inheritdoc IERC4626 + function maxRedeem(address operator) external view returns (uint256 maxShares) { + maxShares = manager.maxRedeem(address(this), operator); } - /// @notice Request cancelling the outstanding deposit orders. Will return the assets once the order - /// on Centrifuge is successfully cancelled. - function cancelDepositRequest() public { - investmentManager.cancelDepositRequest(address(this), msg.sender); - emit CancelDepositRequest(msg.sender); + /// @inheritdoc IERC4626 + /// @notice DOES NOT support operator != msg.sender since shares are already transferred on requestRedeem + function redeem(uint256 shares, address receiver, address operator) external returns (uint256 assets) { + require((msg.sender == operator), "LiquidityPool/not-the-operator"); + assets = manager.redeem(address(this), shares, receiver, operator); + emit Withdraw(msg.sender, receiver, operator, assets, shares); } - /// @notice Request share redemption for a receiver to be included in the next epoch execution. - /// @notice Request can only be called by the owner of the shares - /// Shares are locked in the escrow on request submission - function requestRedeem(uint256 shares) public { - investmentManager.requestRedeem(address(this), shares, msg.sender); - emit RedeemRequest(msg.sender, shares); + /// @dev Preview functions for ERC-7540 vaults revert + function previewDeposit(uint256) external pure returns (uint256) { + revert(); } - /// @notice Request decreasing the outstanding redemption orders. Will return the shares once the order - /// on Centrifuge is successfully decreased. - function decreaseRedeemRequest(uint256 shares) public { - investmentManager.decreaseRedeemRequest(address(this), shares, msg.sender); - emit DecreaseRedeemRequest(msg.sender, shares); + /// @dev Preview functions for ERC-7540 vaults revert + function previewMint(uint256) external pure returns (uint256) { + revert(); } - /// @notice Request cancelling the outstanding redemption orders. Will return the shares once the order - /// on Centrifuge is successfully cancelled. - function cancelRedeemRequest() public { - investmentManager.cancelRedeemRequest(address(this), msg.sender); - emit CancelRedeemRequest(msg.sender); + /// @dev Preview functions for ERC-7540 vaults revert + function previewWithdraw(uint256) external pure returns (uint256) { + revert(); } - /// @notice View the total amount the user has requested to redeem but isn't able to withdraw or redeem yet - function userRedeemRequest(address user) external view returns (uint256 shares) { - shares = investmentManager.userRedeemRequest(address(this), user); + /// @dev Preview functions for ERC-7540 vaults revert + function previewRedeem(uint256) external pure returns (uint256) { + revert(); } - // --- ERC20 overrides --- - function name() public view returns (string memory) { + // --- ERC-20 overrides --- + /// @inheritdoc IERC20Metadata + function name() external view returns (string memory) { return share.name(); } - function symbol() public view returns (string memory) { + /// @inheritdoc IERC20Metadata + function symbol() external view returns (string memory) { return share.symbol(); } - function decimals() public view returns (uint8) { + /// @inheritdoc IERC20Metadata + function decimals() external view returns (uint8) { return share.decimals(); } + /// @inheritdoc IERC20 function totalSupply() public view returns (uint256) { return share.totalSupply(); } - function balanceOf(address owner) public view returns (uint256) { + /// @inheritdoc IERC20 + function balanceOf(address owner) external view returns (uint256) { return share.balanceOf(owner); } - function allowance(address owner, address spender) public view returns (uint256) { + /// @inheritdoc IERC20 + function allowance(address owner, address spender) external view returns (uint256) { return share.allowance(owner, spender); } - function transferFrom(address, address, uint256) public returns (bool) { - (bool success, bytes memory data) = address(share).call(bytes.concat(msg.data, bytes20(msg.sender))); + /// @inheritdoc IERC20 + function transferFrom(address from, address to, uint256 value) public returns (bool) { + (bool success, bytes memory data) = address(share).call( + bytes.concat( + abi.encodeWithSignature("transferFrom(address,address,uint256)", from, to, value), bytes20(msg.sender) + ) + ); _successCheck(success); return abi.decode(data, (bool)); } - function transfer(address, uint256) public returns (bool) { + /// @inheritdoc IERC20 + function transfer(address, uint256) external returns (bool) { (bool success, bytes memory data) = address(share).call(bytes.concat(msg.data, bytes20(msg.sender))); _successCheck(success); return abi.decode(data, (bool)); } - function approve(address, uint256) public returns (bool) { + /// @inheritdoc IERC20 + function approve(address, uint256) external returns (bool) { (bool success, bytes memory data) = address(share).call(bytes.concat(msg.data, bytes20(msg.sender))); _successCheck(success); return abi.decode(data, (bool)); } - // --- Pricing --- - function updatePrice(uint128 price) public auth { - latestPrice = price; - lastPriceUpdate = block.timestamp; - emit PriceUpdate(price); + // --- Helpers --- + function emitDepositClaimable(address operator, uint256 assets, uint256 shares) public auth { + emit DepositClaimable(operator, assets, shares); } - // --- Helpers --- - function _withPermit( - address token, - address owner, - address spender, - uint256 value, - uint256 deadline, - uint8 v, - bytes32 r, - bytes32 s - ) internal { - try ERC20PermitLike(token).permit(owner, spender, value, deadline, v, r, s) { - return; - } catch { - if (IERC20(token).allowance(owner, spender) >= value) { - return; - } - } - revert("LiquidityPool/permit-failure"); + function emitRedeemClaimable(address operator, uint256 assets, uint256 shares) public auth { + emit RedeemClaimable(operator, assets, shares); } - /// @dev In case of unsuccessful tx, parse the revert message function _successCheck(bool success) internal pure { if (!success) { assembly { diff --git a/src/PoolManager.sol b/src/PoolManager.sol index 78946672..dedb23fb 100644 --- a/src/PoolManager.sol +++ b/src/PoolManager.sol @@ -4,9 +4,10 @@ pragma solidity 0.8.21; import {TrancheTokenFactoryLike, RestrictionManagerFactoryLike, LiquidityPoolFactoryLike} from "./util/Factory.sol"; import {TrancheTokenLike} from "./token/Tranche.sol"; import {RestrictionManagerLike} from "./token/RestrictionManager.sol"; -import {IERC20} from "./interfaces/IERC20.sol"; +import {IERC20Metadata} from "./interfaces/IERC20.sol"; import {Auth} from "./util/Auth.sol"; import {SafeTransferLib} from "./util/SafeTransferLib.sol"; +import {MathLib} from "./util/MathLib.sol"; interface GatewayLike { function transferTrancheTokensToCentrifuge( @@ -45,7 +46,7 @@ interface AuthLike { struct Pool { uint256 createdAt; mapping(bytes16 trancheId => Tranche) tranches; - mapping(address currencyAddress => bool) allowedCurrencies; + mapping(address currency => bool) allowedCurrencies; } /// @dev Each Centrifuge pool is associated to 1 or more tranches @@ -53,7 +54,14 @@ struct Tranche { address token; /// @dev Each tranche can have multiple liquidity pools deployed, /// each linked to a unique investment currency (asset) - mapping(address currencyAddress => address liquidityPool) liquidityPools; + mapping(address currency => address liquidityPool) liquidityPools; + /// @dev Each tranche has a price per liquidity pool + mapping(address liquidityPool => TrancheTokenPrice) prices; +} + +struct TrancheTokenPrice { + uint256 price; + uint64 computedAt; } /// @dev Temporary storage that is only present between addTranche and deployTranche @@ -70,16 +78,18 @@ struct UndeployedTranche { /// @notice This contract manages which pools & tranches exist, /// as well as managing allowed pool currencies, and incoming and outgoing transfers. contract PoolManager is Auth { + using MathLib for uint256; + uint8 internal constant MIN_DECIMALS = 1; uint8 internal constant MAX_DECIMALS = 18; EscrowLike public immutable escrow; LiquidityPoolFactoryLike public immutable liquidityPoolFactory; - RestrictionManagerFactoryLike public immutable restrictionManagerFactory; TrancheTokenFactoryLike public immutable trancheTokenFactory; GatewayLike public gateway; InvestmentManagerLike public investmentManager; + RestrictionManagerFactoryLike public restrictionManagerFactory; mapping(uint64 poolId => Pool) public pools; mapping(uint64 poolId => mapping(bytes16 => UndeployedTranche)) public undeployedTranches; @@ -90,14 +100,19 @@ contract PoolManager is Auth { // --- Events --- event File(bytes32 indexed what, address data); + event AddCurrency(uint128 indexed currencyId, address indexed currency); event AddPool(uint64 indexed poolId); - event AllowInvestmentCurrency(uint128 indexed currency, uint64 indexed poolId); - event DisallowInvestmentCurrency(uint128 indexed currency, uint64 indexed poolId); + event AllowInvestmentCurrency(uint64 indexed poolId, address indexed currency); + event DisallowInvestmentCurrency(uint64 indexed poolId, address indexed currency); event AddTranche(uint64 indexed poolId, bytes16 indexed trancheId); - event DeployTranche(uint64 indexed poolId, bytes16 indexed trancheId, address indexed token); - event AddCurrency(uint128 indexed currency, address indexed currencyAddress); - event DeployLiquidityPool(uint64 indexed poolId, bytes16 indexed trancheId, address indexed liquidityPool); - event TransferCurrency(address indexed currencyAddress, bytes32 indexed recipient, uint128 amount); + event DeployTranche(uint64 indexed poolId, bytes16 indexed trancheId, address indexed trancheToken); + event DeployLiquidityPool( + uint64 indexed poolId, bytes16 indexed trancheId, address indexed currency, address liquidityPool + ); + event PriceUpdate( + uint64 indexed poolId, bytes16 indexed trancheId, address indexed currency, uint256 price, uint64 computedAt + ); + event TransferCurrency(address indexed currency, bytes32 indexed recipient, uint128 amount); event TransferTrancheTokensToCentrifuge( uint64 indexed poolId, bytes16 indexed trancheId, bytes32 destinationAddress, uint128 amount ); @@ -134,24 +149,20 @@ contract PoolManager is Auth { function file(bytes32 what, address data) external auth { if (what == "gateway") gateway = GatewayLike(data); else if (what == "investmentManager") investmentManager = InvestmentManagerLike(data); + else if (what == "restrictionManagerFactory") restrictionManagerFactory = RestrictionManagerFactoryLike(data); else revert("PoolManager/file-unrecognized-param"); emit File(what, data); } // --- Outgoing message handling --- - function transfer(address currencyAddress, bytes32 recipient, uint128 amount) public { - uint128 currency = currencyAddressToId[currencyAddress]; - require(currency != 0, "PoolManager/unknown-currency"); - - // Transfer the currency amount from user to escrow (lock currency in escrow) - // Checks actual balance difference to support fee-on-transfer tokens - uint256 preBalance = IERC20(currencyAddress).balanceOf(address(escrow)); - SafeTransferLib.safeTransferFrom(currencyAddress, msg.sender, address(escrow), amount); - uint256 postBalance = IERC20(currencyAddress).balanceOf(address(escrow)); - uint128 transferredAmount = _toUint128(postBalance - preBalance); - - gateway.transfer(currency, msg.sender, recipient, transferredAmount); - emit TransferCurrency(currencyAddress, recipient, transferredAmount); + function transfer(address currency, bytes32 recipient, uint128 amount) external { + uint128 currencyId = currencyAddressToId[currency]; + require(currencyId != 0, "PoolManager/unknown-currency"); + + SafeTransferLib.safeTransferFrom(currency, msg.sender, address(escrow), amount); + + gateway.transfer(currencyId, msg.sender, recipient, amount); + emit TransferCurrency(currency, recipient, amount); } function transferTrancheTokensToCentrifuge( @@ -159,7 +170,7 @@ contract PoolManager is Auth { bytes16 trancheId, bytes32 destinationAddress, uint128 amount - ) public { + ) external { TrancheTokenLike trancheToken = TrancheTokenLike(getTrancheToken(poolId, trancheId)); require(address(trancheToken) != address(0), "PoolManager/unknown-token"); @@ -175,7 +186,7 @@ contract PoolManager is Auth { uint64 destinationChainId, address destinationAddress, uint128 amount - ) public { + ) external { TrancheTokenLike trancheToken = TrancheTokenLike(getTrancheToken(poolId, trancheId)); require(address(trancheToken) != address(0), "PoolManager/unknown-token"); @@ -201,26 +212,26 @@ contract PoolManager is Auth { /// a new supported currency to the pool details. /// Adding new currencies allow the creation of new liquidity pools for the underlying Centrifuge pool. /// @dev The function can only be executed by the gateway contract. - function allowInvestmentCurrency(uint64 poolId, uint128 currency) public onlyGateway { + function allowInvestmentCurrency(uint64 poolId, uint128 currencyId) public onlyGateway { Pool storage pool = pools[poolId]; require(pool.createdAt != 0, "PoolManager/invalid-pool"); - address currencyAddress = currencyIdToAddress[currency]; - require(currencyAddress != address(0), "PoolManager/unknown-currency"); + address currency = currencyIdToAddress[currencyId]; + require(currency != address(0), "PoolManager/unknown-currency"); - pools[poolId].allowedCurrencies[currencyAddress] = true; - emit AllowInvestmentCurrency(currency, poolId); + pools[poolId].allowedCurrencies[currency] = true; + emit AllowInvestmentCurrency(poolId, currency); } - function disallowInvestmentCurrency(uint64 poolId, uint128 currency) public onlyGateway { + function disallowInvestmentCurrency(uint64 poolId, uint128 currencyId) public onlyGateway { Pool storage pool = pools[poolId]; require(pool.createdAt != 0, "PoolManager/invalid-pool"); - address currencyAddress = currencyIdToAddress[currency]; - require(currencyAddress != address(0), "PoolManager/unknown-currency"); + address currency = currencyIdToAddress[currencyId]; + require(currency != address(0), "PoolManager/unknown-currency"); - pools[poolId].allowedCurrencies[currencyAddress] = false; - emit DisallowInvestmentCurrency(currency, poolId); + pools[poolId].allowedCurrencies[currency] = false; + emit DisallowInvestmentCurrency(poolId, currency); } /// @notice New tranche details from an existing Centrifuge pool are added. @@ -240,6 +251,7 @@ contract PoolManager is Auth { UndeployedTranche storage undeployedTranche = undeployedTranches[poolId][trancheId]; require(undeployedTranche.decimals == 0, "PoolManager/tranche-already-exists"); + require(getTrancheToken(poolId, trancheId) == address(0), "PoolManager/tranche-already-deployed"); undeployedTranche.decimals = decimals; undeployedTranche.tokenName = tokenName; @@ -261,6 +273,23 @@ contract PoolManager is Auth { trancheToken.file("symbol", tokenSymbol); } + function updateTrancheTokenPrice( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId, + uint128 price, + uint64 computedAt + ) public onlyGateway { + Tranche storage tranche = pools[poolId].tranches[trancheId]; + require(tranche.token != address(0), "PoolManager/tranche-does-not-exist"); + + address currency = currencyIdToAddress[currencyId]; + require(computedAt >= tranche.prices[currency].computedAt, "PoolManager/cannot-set-older-price"); + + tranche.prices[currency] = TrancheTokenPrice(price, computedAt); + emit PriceUpdate(poolId, trancheId, currency, price, computedAt); + } + function updateMember(uint64 poolId, bytes16 trancheId, address user, uint64 validUntil) public onlyGateway { require(user != address(escrow), "PoolManager/escrow-member-cannot-be-updated"); @@ -293,32 +322,32 @@ contract PoolManager is Auth { /// a currency from the Centrifuge index to its corresponding address on the evm chain. /// The chain agnostic currency id has to be used to pass currency information to the Centrifuge. /// @dev This function can only be executed by the gateway contract. - function addCurrency(uint128 currency, address currencyAddress) public onlyGateway { + function addCurrency(uint128 currencyId, address currency) public onlyGateway { // Currency index on the Centrifuge side should start at 1 - require(currency != 0, "PoolManager/currency-id-has-to-be-greater-than-0"); - require(currencyIdToAddress[currency] == address(0), "PoolManager/currency-id-in-use"); - require(currencyAddressToId[currencyAddress] == 0, "PoolManager/currency-address-in-use"); + require(currencyId != 0, "PoolManager/currency-id-has-to-be-greater-than-0"); + require(currencyIdToAddress[currencyId] == address(0), "PoolManager/currency-id-in-use"); + require(currencyAddressToId[currency] == 0, "PoolManager/currency-address-in-use"); - uint8 currencyDecimals = IERC20(currencyAddress).decimals(); + uint8 currencyDecimals = IERC20Metadata(currency).decimals(); require(currencyDecimals >= MIN_DECIMALS, "PoolManager/too-few-currency-decimals"); require(currencyDecimals <= MAX_DECIMALS, "PoolManager/too-many-currency-decimals"); - currencyIdToAddress[currency] = currencyAddress; - currencyAddressToId[currencyAddress] = currency; + currencyIdToAddress[currencyId] = currency; + currencyAddressToId[currency] = currencyId; // Give investment manager infinite approval for currency in the escrow // to transfer to the user escrow on redeem, withdraw or transfer - escrow.approve(currencyAddress, investmentManager.userEscrow(), type(uint256).max); + escrow.approve(currency, investmentManager.userEscrow(), type(uint256).max); - emit AddCurrency(currency, currencyAddress); + emit AddCurrency(currencyId, currency); } - function handleTransfer(uint128 currency, address recipient, uint128 amount) public onlyGateway { - address currencyAddress = currencyIdToAddress[currency]; - require(currencyAddress != address(0), "PoolManager/unknown-currency"); + function handleTransfer(uint128 currencyId, address recipient, uint128 amount) public onlyGateway { + address currency = currencyIdToAddress[currencyId]; + require(currency != address(0), "PoolManager/unknown-currency"); - escrow.approve(currencyAddress, address(this), amount); - SafeTransferLib.safeTransferFrom(currencyAddress, address(escrow), recipient, amount); + escrow.approve(currency, address(this), amount); + SafeTransferLib.safeTransferFrom(currency, address(escrow), recipient, amount); } function handleTransferTrancheTokens(uint64 poolId, bytes16 trancheId, address destinationAddress, uint128 amount) @@ -378,7 +407,7 @@ contract PoolManager is Auth { // Deploy liquidity pool liquidityPool = liquidityPoolFactory.newLiquidityPool( - poolId, trancheId, currency, tranche.token, address(investmentManager), liquidityPoolWards + poolId, trancheId, currency, tranche.token, address(escrow), address(investmentManager), liquidityPoolWards ); tranche.liquidityPools[currency] = liquidityPool; @@ -393,11 +422,11 @@ contract PoolManager is Auth { // in the escrow to transfer to the user on deposit or mint escrow.approve(tranche.token, address(investmentManager), type(uint256).max); - // Give investment manager infinite approval for tranche tokens + // Give liquidity pool infinite approval for tranche tokens // in the escrow to burn on executed redemptions escrow.approve(tranche.token, liquidityPool, type(uint256).max); - emit DeployLiquidityPool(poolId, trancheId, liquidityPool); + emit DeployLiquidityPool(poolId, trancheId, currency, liquidityPool); return liquidityPool; } @@ -407,28 +436,25 @@ contract PoolManager is Auth { return tranche.token; } + function getLiquidityPool(uint64 poolId, bytes16 trancheId, uint128 currencyId) public view returns (address) { + return pools[poolId].tranches[trancheId].liquidityPools[currencyIdToAddress[currencyId]]; + } + function getLiquidityPool(uint64 poolId, bytes16 trancheId, address currency) public view returns (address) { return pools[poolId].tranches[trancheId].liquidityPools[currency]; } - function isAllowedAsInvestmentCurrency(uint64 poolId, address currencyAddress) public view returns (bool) { - uint128 currency = currencyAddressToId[currencyAddress]; - if (currency == 0) { - // Currency index on the Centrifuge side should start at 1 - return false; - } - - return pools[poolId].allowedCurrencies[currencyAddress]; + function getTrancheTokenPrice(uint64 poolId, bytes16 trancheId, address currency) + public + view + returns (uint256 price, uint64 computedAt) + { + TrancheTokenPrice memory value = pools[poolId].tranches[trancheId].prices[currency]; + price = value.price; + computedAt = value.computedAt; } - /// @dev Safe type conversion from uint256 to uint128. Revert if value is too big to - /// be stored with uint128. Avoid data loss. - /// @return value - safely converted without data loss - function _toUint128(uint256 _value) internal pure returns (uint128 value) { - if (_value > type(uint128).max) { - revert("PoolManager/uint128-overflow"); - } else { - value = uint128(_value); - } + function isAllowedAsInvestmentCurrency(uint64 poolId, address currency) public view returns (bool) { + return pools[poolId].allowedCurrencies[currency]; } } diff --git a/src/Root.sol b/src/Root.sol index 2ba5495e..0c95b086 100644 --- a/src/Root.sol +++ b/src/Root.sol @@ -74,13 +74,14 @@ contract Root is Auth { /// @notice Cancel a pending scheduled rely function cancelRely(address target) external auth { + require(schedule[target] != 0, "Root/target-not-scheduled"); schedule[target] = 0; emit CancelRely(target); } /// @notice Execute a scheduled rely /// @dev Can be triggered by anyone since the scheduling is protected - function executeScheduledRely(address target) public { + function executeScheduledRely(address target) external { require(schedule[target] != 0, "Root/target-not-scheduled"); require(schedule[target] <= block.timestamp, "Root/target-not-ready"); @@ -92,13 +93,13 @@ contract Root is Auth { /// --- External contract ward management --- /// @notice Make an address a ward on any contract that Root is a ward on - function relyContract(address target, address user) public auth { + function relyContract(address target, address user) external auth { AuthLike(target).rely(user); emit RelyContract(target, user); } /// @notice Removes an address as a ward on any contract that Root is a ward on - function denyContract(address target, address user) public auth { + function denyContract(address target, address user) external auth { AuthLike(target).deny(user); emit DenyContract(target, user); } diff --git a/src/admins/DelayedAdmin.sol b/src/admins/DelayedAdmin.sol index 560d7602..8edbbaf3 100644 --- a/src/admins/DelayedAdmin.sol +++ b/src/admins/DelayedAdmin.sol @@ -27,28 +27,28 @@ contract DelayedAdmin is Auth { } // --- Admin actions --- - function pause() public auth { + function pause() external auth { root.pause(); } - function unpause() public auth { + function unpause() external auth { root.unpause(); } - function scheduleRely(address target) public auth { + function scheduleRely(address target) external auth { root.scheduleRely(target); } - function cancelRely(address target) public auth { + function cancelRely(address target) external auth { root.cancelRely(target); } // --- PauseAdmin management --- - function addPauser(address user) public auth { + function addPauser(address user) external auth { pauseAdmin.addPauser(user); } - function removePauser(address user) public auth { + function removePauser(address user) external auth { pauseAdmin.removePauser(user); } } diff --git a/src/admins/PauseAdmin.sol b/src/admins/PauseAdmin.sol index 15fa51ad..91b46819 100644 --- a/src/admins/PauseAdmin.sol +++ b/src/admins/PauseAdmin.sol @@ -39,7 +39,7 @@ contract PauseAdmin is Auth { } // --- Admin actions --- - function pause() public canPause { + function pause() external canPause { root.pause(); } } diff --git a/src/gateway/Gateway.sol b/src/gateway/Gateway.sol index bad95326..f5af0057 100644 --- a/src/gateway/Gateway.sol +++ b/src/gateway/Gateway.sol @@ -5,7 +5,6 @@ import {Messages} from "./Messages.sol"; import {Auth} from "./../util/Auth.sol"; interface InvestmentManagerLike { - function updateTrancheTokenPrice(uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) external; function handleExecutedDecreaseInvestOrder( uint64 poolId, bytes16 trancheId, @@ -69,6 +68,13 @@ interface PoolManagerLike { string memory tokenName, string memory tokenSymbol ) external; + function updateTrancheTokenPrice( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId, + uint128 price, + uint64 computedAt + ) external; function addCurrency(uint128 currency, address currencyAddress) external; function handleTransfer(uint128 currency, address recipient, uint128 amount) external; function handleTransferTrancheTokens(uint64 poolId, bytes16 trancheId, address destinationAddress, uint128 amount) @@ -93,11 +99,11 @@ interface RootLike { /// will not be forwarded contract Gateway is Auth { RootLike public immutable root; - InvestmentManagerLike public investmentManager; PoolManagerLike public poolManager; + InvestmentManagerLike public investmentManager; - mapping(address => bool) public incomingRouters; RouterLike public outgoingRouter; + mapping(address => bool) public incomingRouters; // --- Events --- event File(bytes32 indexed what, address data); @@ -310,9 +316,9 @@ contract Gateway is Auth { (uint64 poolId, bytes16 trancheId, address user, uint64 validUntil) = Messages.parseUpdateMember(message); poolManager.updateMember(poolId, trancheId, user, validUntil); } else if (Messages.isUpdateTrancheTokenPrice(message)) { - (uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) = + (uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price, uint64 computedAt) = Messages.parseUpdateTrancheTokenPrice(message); - investmentManager.updateTrancheTokenPrice(poolId, trancheId, currencyId, price); + poolManager.updateTrancheTokenPrice(poolId, trancheId, currencyId, price, computedAt); } else if (Messages.isTransfer(message)) { (uint128 currency, address recipient, uint128 amount) = Messages.parseIncomingTransfer(message); poolManager.handleTransfer(currency, recipient, amount); diff --git a/src/gateway/Messages.sol b/src/gateway/Messages.sol index d50880d9..abdbefa7 100644 --- a/src/gateway/Messages.sol +++ b/src/gateway/Messages.sol @@ -225,13 +225,16 @@ library Messages { * 9-24: trancheId (16 bytes) * 25-40: currency (uint128 = 16 bytes) * 41-56: price (uint128 = 16 bytes) + * 57-64: computedAt (uint64 = 8 bytes) */ - function formatUpdateTrancheTokenPrice(uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) - internal - pure - returns (bytes memory) - { - return abi.encodePacked(uint8(Call.UpdateTrancheTokenPrice), poolId, trancheId, currencyId, price); + function formatUpdateTrancheTokenPrice( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId, + uint128 price, + uint64 computedAt + ) internal pure returns (bytes memory) { + return abi.encodePacked(uint8(Call.UpdateTrancheTokenPrice), poolId, trancheId, currencyId, price, computedAt); } function isUpdateTrancheTokenPrice(bytes memory _msg) internal pure returns (bool) { @@ -241,12 +244,13 @@ library Messages { function parseUpdateTrancheTokenPrice(bytes memory _msg) internal pure - returns (uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) + returns (uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price, uint64 computedAt) { poolId = BytesLib.toUint64(_msg, 1); trancheId = BytesLib.toBytes16(_msg, 9); currencyId = BytesLib.toUint128(_msg, 25); price = BytesLib.toUint128(_msg, 41); + computedAt = BytesLib.toUint64(_msg, 57); } /* diff --git a/src/interfaces/IERC20.sol b/src/interfaces/IERC20.sol index 69b4f97b..ef7bdcb0 100644 --- a/src/interfaces/IERC20.sol +++ b/src/interfaces/IERC20.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: MIT -// pragma solidity 0.8.21; /// @title IERC20 @@ -75,8 +74,103 @@ interface IERC20 { * Emits a {Transfer} event. */ function transferFrom(address from, address to, uint256 value) external returns (bool); +} +/** + * @dev Interface for the optional metadata functions from the ERC20 standard. + */ +interface IERC20Metadata is IERC20 { + /** + * @dev Returns the name of the token. + */ function name() external view returns (string memory); + + /** + * @dev Returns the symbol of the token. + */ function symbol() external view returns (string memory); + + /** + * @dev Returns the decimals places of the token. + */ function decimals() external view returns (uint8); } + +/** + * @dev Interface of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in + * https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]. + * + * Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by + * presenting a message signed by the account. By not relying on {IERC20-approve}, the token holder account doesn't + * need to send a transaction, and thus is not required to hold Ether at all. + * + * ==== Security Considerations + * + * There are two important considerations concerning the use of `permit`. The first is that a valid permit signature + * expresses an allowance, and it should not be assumed to convey additional meaning. In particular, it should not be + * considered as an intention to spend the allowance in any specific way. The second is that because permits have + * built-in replay protection and can be submitted by anyone, they can be frontrun. A protocol that uses permits should + * take this into consideration and allow a `permit` call to fail. Combining these two aspects, a pattern that may be + * generally recommended is: + * + * ```solidity + * function doThingWithPermit(..., uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public { + * try token.permit(msg.sender, address(this), value, deadline, v, r, s) {} catch {} + * doThing(..., value); + * } + * + * function doThing(..., uint256 value) public { + * token.safeTransferFrom(msg.sender, address(this), value); + * ... + * } + * ``` + * + * Observe that: 1) `msg.sender` is used as the owner, leaving no ambiguity as to the signer intent, and 2) the use of + * `try/catch` allows the permit to fail and makes the code tolerant to frontrunning. (See also + * {SafeERC20-safeTransferFrom}). + * + * Additionally, note that smart contract wallets (such as Argent or Safe) are not able to produce permit signatures, so + * contracts should have entry points that don't rely on permit. + */ +interface IERC20Permit { + /** + * @dev Sets `value` as the allowance of `spender` over ``owner``'s tokens, + * given ``owner``'s signed approval. + * + * IMPORTANT: The same issues {IERC20-approve} has related to transaction + * ordering also apply here. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `deadline` must be a timestamp in the future. + * - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner` + * over the EIP712-formatted function arguments. + * - the signature must use ``owner``'s current nonce (see {nonces}). + * + * For more information on the signature format, see the + * https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP + * section]. + * + * CAUTION: See Security Considerations above. + */ + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; + + /** + * @dev Returns the current nonce for `owner`. This value must be + * included whenever a signature is generated for {permit}. + * + * Every successful call to {permit} increases ``owner``'s nonce by one. This + * prevents a signature from being used multiple times. + */ + function nonces(address owner) external view returns (uint256); + + /** + * @dev Returns the domain separator used in the encoding of the signature for {permit}, as defined by {EIP712}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/src/interfaces/IERC4626.sol b/src/interfaces/IERC4626.sol index c220eb34..fcf0f4ed 100644 --- a/src/interfaces/IERC4626.sol +++ b/src/interfaces/IERC4626.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.21; -import {IERC20} from "./IERC20.sol"; +import {IERC20Metadata} from "./IERC20.sol"; /// @title IERC4626 /// @dev Interface of the ERC4626 "Tokenized Vault Standard", as defined in /// https://eips.ethereum.org/EIPS/eip-4626[ERC-4626]. /// @author Modified from OpenZeppelin Contracts (last updated v4.9.0) (interfaces/IERC4626.sol) -interface IERC4626 is IERC20 { +interface IERC4626 is IERC20Metadata { event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); event Withdraw( diff --git a/src/interfaces/IERC7540.sol b/src/interfaces/IERC7540.sol new file mode 100644 index 00000000..344d03dc --- /dev/null +++ b/src/interfaces/IERC7540.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import {IERC4626} from "./IERC4626.sol"; + +/** + * @dev Interface of the ERC165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[EIP]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +interface IERC7540Deposit { + event DepositRequest(address indexed sender, address indexed operator, uint256 assets); + + /** + * @dev Transfers assets from msg.sender into the Vault and submits a Request for asynchronous deposit/mint. + * + * - MUST support ERC-20 approve / transferFrom on asset as a deposit Request flow. + * - MUST revert if all of assets cannot be requested for deposit/mint. + * + * NOTE: most implementations will require pre-approval of the Vault with the Vault's underlying asset token. + */ + function requestDeposit(uint256 assets, address operator) external; + + /** + * @dev Returns the amount of requested assets in Pending state for the operator to deposit or mint. + * + * - MUST NOT include any assets in Claimable state for deposit or mint. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function pendingDepositRequest(address operator) external view returns (uint256 assets); +} + +interface IERC7540Redeem { + event RedeemRequest(address indexed sender, address indexed operator, address indexed owner, uint256 shares); + + /** + * @dev Assumes control of shares from owner and submits a Request for asynchronous redeem/withdraw. + * + * - MUST support a redeem Request flow where the control of shares is taken from owner directly + * where msg.sender has ERC-20 approval over the shares of owner. + * - MUST revert if all of shares cannot be requested for redeem / withdraw. + */ + function requestRedeem(uint256 shares, address operator, address owner) external; + + /** + * @dev Returns the amount of requested shares in Pending state for the operator to redeem or withdraw. + * + * - MUST NOT include any shares in Claimable state for redeem or withdraw. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + */ + function pendingRedeemRequest(address operator) external view returns (uint256 shares); +} + +/** + * @title IERC7540 + * @dev Interface of the ERC7540 "Asynchronous Tokenized Vault Standard", as defined in + * https://github.com/ethereum/EIPs/blob/2e63f2096b0c7d8388458bb0a03a7ce0eb3422a4/EIPS/eip-7540.md[ERC-7540]. + */ +interface IERC7540 is IERC7540Deposit, IERC7540Redeem, IERC4626, IERC165 {} diff --git a/src/token/RestrictionManager.sol b/src/token/RestrictionManager.sol index bc8b6b49..332b25ca 100644 --- a/src/token/RestrictionManager.sol +++ b/src/token/RestrictionManager.sol @@ -17,20 +17,22 @@ interface RestrictionManagerLike { contract RestrictionManager is Auth { string internal constant SUCCESS_MESSAGE = "RestrictionManager/transfer-allowed"; string internal constant SOURCE_IS_FROZEN_MESSAGE = "RestrictionManager/source-is-frozen"; + string internal constant DESTINATION_IS_FROZEN_MESSAGE = "RestrictionManager/destination-is-frozen"; string internal constant DESTINATION_NOT_A_MEMBER_RESTRICTION_MESSAGE = "RestrictionManager/destination-not-a-member"; uint8 public constant SUCCESS_CODE = 0; uint8 public constant SOURCE_IS_FROZEN_CODE = 1; - uint8 public constant DESTINATION_NOT_A_MEMBER_RESTRICTION_CODE = 2; + uint8 public constant DESTINATION_IS_FROZEN_CODE = 2; + uint8 public constant DESTINATION_NOT_A_MEMBER_RESTRICTION_CODE = 3; IERC20 public immutable token; - /// @dev Frozen accounts that tokens cannot be transferred from - mapping(address => uint256) public frozen; + /// @dev Frozen accounts that tokens cannot be transferred from or to + mapping(address => uint256 isFrozen) public frozen; - /// @dev Member accounts that tokens can be transferred to - mapping(address => uint256) public members; + /// @dev Member accounts that tokens can be transferred to, with an end date + mapping(address => uint256 validUntil) public members; // --- Events --- event UpdateMember(address indexed user, uint256 validUntil); @@ -50,6 +52,10 @@ contract RestrictionManager is Auth { return SOURCE_IS_FROZEN_CODE; } + if (frozen[to] == 1) { + return DESTINATION_IS_FROZEN_CODE; + } + if (!hasMember(to)) { return DESTINATION_NOT_A_MEMBER_RESTRICTION_CODE; } @@ -62,6 +68,10 @@ contract RestrictionManager is Auth { return SOURCE_IS_FROZEN_MESSAGE; } + if (restrictionCode == DESTINATION_IS_FROZEN_CODE) { + return DESTINATION_IS_FROZEN_MESSAGE; + } + if (restrictionCode == DESTINATION_NOT_A_MEMBER_RESTRICTION_CODE) { return DESTINATION_NOT_A_MEMBER_RESTRICTION_MESSAGE; } @@ -71,6 +81,7 @@ contract RestrictionManager is Auth { // --- Handling freezes --- function freeze(address user) public auth { + require(user != address(0), "RestrictionManager/cannot-freeze-zero-address"); frozen[user] = 1; emit Freeze(user); } diff --git a/src/token/Tranche.sol b/src/token/Tranche.sol index fe235775..cef98941 100644 --- a/src/token/Tranche.sol +++ b/src/token/Tranche.sol @@ -43,7 +43,7 @@ contract TrancheToken is ERC20 { } // --- Administration --- - function file(bytes32 what, address data) public auth { + function file(bytes32 what, address data) external auth { if (what == "restrictionManager") restrictionManager = ERC1404Like(data); else revert("TrancheToken/file-unrecognized-param"); emit File(what, data); diff --git a/src/util/Factory.sol b/src/util/Factory.sol index cb4773b5..09ceb6fc 100644 --- a/src/util/Factory.sol +++ b/src/util/Factory.sol @@ -16,6 +16,7 @@ interface LiquidityPoolFactoryLike { bytes16 trancheId, address currency, address trancheToken, + address escrow, address investmentManager, address[] calldata wards_ ) external returns (address); @@ -38,10 +39,12 @@ contract LiquidityPoolFactory is Auth { bytes16 trancheId, address currency, address trancheToken, + address escrow, address investmentManager, address[] calldata wards_ ) public auth returns (address) { - LiquidityPool liquidityPool = new LiquidityPool(poolId, trancheId, currency, trancheToken, investmentManager); + LiquidityPool liquidityPool = + new LiquidityPool(poolId, trancheId, currency, trancheToken, escrow, investmentManager); liquidityPool.rely(root); for (uint256 i = 0; i < wards_.length; i++) { diff --git a/src/util/MathLib.sol b/src/util/MathLib.sol index 69b4bea9..a876c805 100644 --- a/src/util/MathLib.sol +++ b/src/util/MathLib.sol @@ -109,15 +109,12 @@ library MathLib { return result; } - /// @notice Returns the largest of two numbers. - function max(uint256 a, uint256 b) internal pure returns (uint256) { - return a > b ? a : b; - } - - /** - * @dev Returns the smallest of two numbers. - */ - function min(uint256 a, uint256 b) internal pure returns (uint256) { - return a > b ? b : a; + /// @notice Safe type conversion from uint256 to uint128. + function toUint128(uint256 _value) internal pure returns (uint128 value) { + if (_value > type(uint128).max) { + revert("MathLib/uint128-overflow"); + } else { + value = uint128(_value); + } } } diff --git a/test/Admin.t.sol b/test/Admin.t.sol index 86a5b04d..cb67b170 100644 --- a/test/Admin.t.sol +++ b/test/Admin.t.sol @@ -162,6 +162,9 @@ contract AdminTest is TestSetup { function testCancellingScheduleWorks() public { address spell = vm.addr(1); + vm.expectRevert("Root/target-not-scheduled"); + root.cancelRely(spell); + delayedAdmin.scheduleRely(spell); assertEq(root.schedule(spell), block.timestamp + delay); delayedAdmin.cancelRely(spell); diff --git a/test/Deploy.t.sol b/test/Deploy.t.sol index e8a17cd2..760a4725 100644 --- a/test/Deploy.t.sol +++ b/test/Deploy.t.sol @@ -26,7 +26,7 @@ interface ApproveLike { } contract DeployTest is Test, Deployer { - using MathLib for uint128; + using MathLib for uint256; uint8 constant PRICE_DECIMALS = 18; @@ -84,8 +84,8 @@ contract DeployTest is Test, Deployer { } function depositMint(uint64 poolId, bytes16 trancheId, uint128 price, uint256 amount, LiquidityPool lPool) public { - erc20.approve(address(investmentManager), amount); // add allowance - lPool.requestDeposit(amount); + erc20.approve(address(lPool), amount); // add allowance + lPool.requestDeposit(amount, self); // ensure funds are locked in escrow assertEq(erc20.balanceOf(address(escrow)), amount); @@ -94,11 +94,11 @@ contract DeployTest is Test, Deployer { // trigger executed collectInvest uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 trancheTokensPayout = _toUint128( - uint128(amount).mulDiv( + uint128 trancheTokensPayout = ( + amount.mulDiv( 10 ** (PRICE_DECIMALS - erc20.decimals() + lPool.decimals()), price, MathLib.Rounding.Down ) - ); + ).toUint128(); // Assume an epoch execution happens on cent chain // Assume a bot calls collectInvest for this user on cent chain @@ -131,13 +131,13 @@ contract DeployTest is Test, Deployer { function redeemWithdraw(uint64 poolId, bytes16 trancheId, uint128 price, uint256 amount, LiquidityPool lPool) public { - lPool.requestRedeem(amount); + lPool.requestRedeem(amount, address(this), address(this)); // redeem uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 currencyPayout = _toUint128( - uint128(amount).mulDiv(price, 10 ** (18 - erc20.decimals() + lPool.decimals()), MathLib.Rounding.Down) - ); + uint128 currencyPayout = ( + amount.mulDiv(price, 10 ** (18 - erc20.decimals() + lPool.decimals()), MathLib.Rounding.Down) + ).toUint128(); // Assume an epoch execution happens on cent chain // Assume a bot calls collectRedeem for this user on cent chain vm.prank(address(gateway)); @@ -186,14 +186,6 @@ contract DeployTest is Test, Deployer { return lPool; } - function _toUint128(uint256 _value) internal pure returns (uint128 value) { - if (_value > type(uint128).max) { - revert(); - } else { - value = uint128(_value); - } - } - function newErc20(string memory name, string memory symbol, uint8 decimals) internal returns (ERC20) { ERC20 currency = new ERC20(decimals); currency.file("name", name); diff --git a/test/InvestmentManager.t.sol b/test/InvestmentManager.t.sol index ab978ad4..452054b8 100644 --- a/test/InvestmentManager.t.sol +++ b/test/InvestmentManager.t.sol @@ -6,7 +6,7 @@ import "./TestSetup.t.sol"; interface LiquidityPoolLike { function latestPrice() external view returns (uint128); - function lastPriceUpdate() external view returns (uint256); + function priceComputedAt() external view returns (uint64); } contract InvestmentManagerTest is TestSetup { @@ -29,7 +29,7 @@ contract InvestmentManagerTest is TestSetup { } // --- Administration --- - function testFile(address random) public { + function testFile() public { // fail: unrecognized param vm.expectRevert(bytes("InvestmentManager/file-unrecognized-param")); investmentManager.file("random", self); @@ -37,99 +37,15 @@ contract InvestmentManagerTest is TestSetup { assertEq(address(investmentManager.gateway()), address(gateway)); assertEq(address(investmentManager.poolManager()), address(poolManager)); // success - investmentManager.file("poolManager", random); - assertEq(address(investmentManager.poolManager()), random); - investmentManager.file("gateway", random); - assertEq(address(investmentManager.gateway()), random); + investmentManager.file("poolManager", randomUser); + assertEq(address(investmentManager.poolManager()), randomUser); + investmentManager.file("gateway", randomUser); + assertEq(address(investmentManager.gateway()), randomUser); // remove self from wards investmentManager.deny(self); // auth fail vm.expectRevert(bytes("Auth/not-authorized")); - investmentManager.file("poolManager", random); - } - - function testUpdatingTokenPriceWorks( - uint64 poolId, - uint8 decimals, - uint128 currencyId, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 price - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(poolId > 0); - vm.assume(trancheId > 0); - vm.assume(currencyId > 0); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currencyId, address(erc20)); // add currency - centrifugeChain.allowInvestmentCurrency(poolId, currencyId); - - poolManager.deployTranche(poolId, trancheId); - LiquidityPoolLike lPool = LiquidityPoolLike(poolManager.deployLiquidityPool(poolId, trancheId, address(erc20))); - - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, price); - assertEq(lPool.latestPrice(), price); - assertEq(lPool.lastPriceUpdate(), block.timestamp); - } - - function testUpdatingTokenPriceAsNonRouterFails( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 price - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - ERC20 erc20 = _newErc20("X's Dollar", "USDX", 18); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - - vm.expectRevert(bytes("InvestmentManager/not-the-gateway")); - investmentManager.updateTrancheTokenPrice(poolId, trancheId, currency, price); - } - - function testUpdatingTokenPriceForNonExistentTrancheFails( - uint64 poolId, - bytes16 trancheId, - uint128 currencyId, - uint128 price - ) public { - centrifugeChain.addPool(poolId); - - vm.expectRevert(bytes("InvestmentManager/tranche-does-not-exist")); - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, price); - } - - function testCollectDeposit(uint128 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); - - investmentManager.collectDeposit(lPool_, self); - } - - function testCollectRedeem(uint128 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.allowInvestmentCurrency(lPool.poolId(), defaultCurrencyId); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); - - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); - investmentManager.collectRedeem(lPool_, self); + investmentManager.file("poolManager", randomUser); } } diff --git a/test/LiquidityPool.t.sol b/test/LiquidityPool.t.sol index e77d2964..edd4a3e1 100644 --- a/test/LiquidityPool.t.sol +++ b/test/LiquidityPool.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.21; import "./TestSetup.t.sol"; +import {IERC7540Deposit, IERC7540Redeem} from "src/interfaces/IERC7540.sol"; contract LiquidityPoolTest is TestSetup { // Deployment @@ -18,7 +19,7 @@ contract LiquidityPoolTest is TestSetup { LiquidityPool lPool = LiquidityPool(lPool_); // values set correctly - assertEq(address(lPool.investmentManager()), address(investmentManager)); + assertEq(address(lPool.manager()), address(investmentManager)); assertEq(lPool.asset(), address(erc20)); assertEq(lPool.poolId(), poolId); assertEq(lPool.trancheId(), trancheId); @@ -37,10 +38,10 @@ contract LiquidityPoolTest is TestSetup { LiquidityPool lPool = LiquidityPool(lPool_); vm.expectRevert(bytes("Auth/not-authorized")); - lPool.file("investmentManager", self); + lPool.file("manager", self); root.relyContract(lPool_, self); - lPool.file("investmentManager", self); + lPool.file("manager", self); vm.expectRevert(bytes("LiquidityPool/file-unrecognized-param")); lPool.file("random", self); @@ -48,164 +49,85 @@ contract LiquidityPoolTest is TestSetup { // --- uint128 type checks --- // Make sure all function calls would fail when overflow uint128 - function testAssertUint128(uint256 amount, address random_) public { + function testAssertUint128(uint256 amount) public { vm.assume(amount > MAX_UINT128); // amount has to overflow UINT128 - vm.assume(random_.code.length == 0); address lPool_ = deploySimplePool(); LiquidityPool lPool = LiquidityPool(lPool_); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); + vm.expectRevert(bytes("MathLib/uint128-overflow")); lPool.convertToShares(amount); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); + vm.expectRevert(bytes("MathLib/uint128-overflow")); lPool.convertToAssets(amount); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.previewDeposit(amount); - - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.previewRedeem(amount); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.deposit(amount, randomUser); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.previewMint(amount); - - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.previewWithdraw(amount); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.mint(amount, randomUser); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.deposit(amount, random_); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.withdraw(amount, randomUser, self); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.mint(amount, random_); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.redeem(amount, randomUser, self); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.withdraw(amount, random_, self); + erc20.mint(address(this), amount); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.requestDeposit(amount, self); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.redeem(amount, random_, self); - - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.requestDeposit(amount); - - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); - lPool.requestRedeem(amount); + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); + root.relyContract(address(trancheToken), self); + trancheToken.mint(address(this), amount); + vm.expectRevert(bytes("MathLib/uint128-overflow")); + lPool.requestRedeem(amount, address(this), address(this)); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); + vm.expectRevert(bytes("MathLib/uint128-overflow")); lPool.decreaseDepositRequest(amount); - vm.expectRevert(bytes("InvestmentManager/uint128-overflow")); + vm.expectRevert(bytes("MathLib/uint128-overflow")); lPool.decreaseRedeemRequest(amount); } - function testRedeemWithApproval(uint256 redemption1, uint256 redemption2) public { - redemption1 = uint128(bound(redemption1, 2, MAX_UINT128)); - redemption2 = uint128(bound(redemption2, 2, MAX_UINT128)); - uint256 amount = redemption1 + redemption2; - vm.assume(amountAssumption(amount)); + // --- erc165 checks --- + function testERC165Support(bytes4 unsupportedInterfaceId) public { + bytes4 erc165 = 0x01ffc9a7; + bytes4 erc7540Deposit = 0xea446681; + bytes4 erc7540Redeem = 0x2e9dd5bd; - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - - deposit(lPool_, investor, amount); // deposit funds first // deposit funds first - - // investor can requestRedeem - vm.prank(investor); - lPool.requestRedeem(amount); - - // fail: ward can not requestRedeem if investment manager has no auth on the tranche token - root.denyContract(address(lPool.share()), address(investmentManager)); - vm.prank(investor); - vm.expectRevert(bytes("Auth/not-authorized")); - lPool.requestRedeem(amount); - root.relyContract(address(lPool.share()), address(investmentManager)); - - uint128 tokenAmount = uint128(lPool.balanceOf(address(escrow))); - centrifugeChain.isExecutedCollectRedeem( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(investor)), - defaultCurrencyId, - uint128(amount), - uint128(tokenAmount), - 0 - ); - - assertEq(lPool.maxRedeem(investor), tokenAmount); - assertEq(lPool.maxWithdraw(investor), uint128(amount)); - - // test for both scenarios redeem & withdraw - - // fail: self cannot redeem for investor - vm.expectRevert(bytes("LiquidityPool/no-approval")); - lPool.redeem(redemption1, investor, investor); - vm.expectRevert(bytes("LiquidityPool/no-approval")); - lPool.withdraw(redemption1, investor, investor); - - // fail: ward can not make requests on behalf of investor - root.relyContract(lPool_, self); - vm.expectRevert(bytes("LiquidityPool/no-approval")); - lPool.redeem(redemption1, investor, investor); - vm.expectRevert(bytes("LiquidityPool/no-approval")); - lPool.withdraw(redemption1, investor, investor); - - // investor redeems rest for himself - vm.prank(investor); - lPool.redeem(redemption1, investor, investor); - vm.prank(investor); - lPool.withdraw(redemption2, investor, investor); - } - - function testMint(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); + vm.assume(unsupportedInterfaceId != erc165 && unsupportedInterfaceId != erc7540Deposit && unsupportedInterfaceId != erc7540Redeem); address lPool_ = deploySimplePool(); LiquidityPool lPool = LiquidityPool(lPool_); - TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); - root.denyContract(address(trancheToken), self); - - vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); - trancheToken.mint(investor, amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); + assertEq(type(IERC7540Deposit).interfaceId, erc7540Deposit); + assertEq(type(IERC7540Redeem).interfaceId, erc7540Redeem); - vm.expectRevert(bytes("Auth/not-authorized")); - trancheToken.mint(investor, amount); + assertEq(lPool.supportsInterface(erc165), true); + assertEq(lPool.supportsInterface(erc7540Deposit), true); + assertEq(lPool.supportsInterface(erc7540Redeem), true); - root.relyContract(address(trancheToken), self); // give self auth permissions - - // success - trancheToken.mint(investor, amount); - assertEq(lPool.balanceOf(investor), amount); - assertEq(lPool.balanceOf(investor), trancheToken.balanceOf(investor)); + assertEq(lPool.supportsInterface(unsupportedInterfaceId), false); } - function testBurn(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - + // --- preview checks --- + function testPreviewReverts(uint256 amount) public { + vm.assume(amount > MAX_UINT128); // amount has to overflow UINT128 address lPool_ = deploySimplePool(); LiquidityPool lPool = LiquidityPool(lPool_); - TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); - root.relyContract(address(trancheToken), self); // give self auth permissions - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); // add investor as member - - trancheToken.mint(investor, amount); - root.denyContract(address(trancheToken), self); // remove auth permissions from self - - vm.expectRevert(bytes("Auth/not-authorized")); - trancheToken.burn(investor, amount); + vm.expectRevert(bytes("")); + lPool.previewDeposit(amount); - root.relyContract(address(trancheToken), self); // give self auth permissions - vm.expectRevert(bytes("ERC20/insufficient-allowance")); - trancheToken.burn(investor, amount); + vm.expectRevert(bytes("")); + lPool.previewRedeem(amount); - // success - vm.prank(investor); - lPool.approve(self, amount); // approve to burn tokens - trancheToken.burn(investor, amount); + vm.expectRevert(bytes("")); + lPool.previewMint(amount); - assertEq(lPool.balanceOf(investor), 0); - assertEq(lPool.balanceOf(investor), trancheToken.balanceOf(investor)); + vm.expectRevert(bytes("")); + lPool.previewWithdraw(amount); } function testTransferFrom(uint256 amount, uint256 transferAmount) public { @@ -218,7 +140,7 @@ contract LiquidityPoolTest is TestSetup { deposit(lPool_, investor, amount); // deposit funds first // deposit funds first LiquidityPool lPool = LiquidityPool(lPool_); centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // put self on memberlist to be able to receive tranche tokens - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); + centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice, uint64(block.timestamp)); TrancheToken trancheToken = TrancheToken(address(lPool.share())); assertEq(trancheToken.isTrustedForwarder(lPool_), true); // Lpool is trusted forwarder on token @@ -263,7 +185,7 @@ contract LiquidityPoolTest is TestSetup { address receiver = makeAddr("receiver"); address lPool_ = deploySimplePool(); LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); + centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice, uint64(block.timestamp)); TrancheToken trancheToken = TrancheToken(address(lPool.share())); assertTrue(trancheToken.isTrustedForwarder(lPool_)); // Lpool is not trusted forwarder on token @@ -339,995 +261,4 @@ contract LiquidityPoolTest is TestSetup { assertEq(lPool.balanceOf(investor), (initBalance - transferAmount)); assertEq(lPool.balanceOf(self), transferAmount); } - - function testDepositAndRedeemPrecision(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { - vm.assume(currencyId > 0); - - uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI - uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC - - ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); - address lPool_ = - deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000000000000000); - - // invest - uint256 investmentAmount = 100000000; // 100 * 10**6 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest of the first 50% at a price of 1.2 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 currencyPayout = 50000000; // 50 * 10**6 - uint128 firstTrancheTokenPayout = 41666666666666666666; // 50 * 10**18 / 1.2, rounded down - centrifugeChain.isExecutedCollectInvest( - poolId, - trancheId, - bytes32(bytes20(self)), - _currencyId, - currencyPayout, - firstTrancheTokenPayout, - currencyPayout / 2 - ); - - // assert deposit & mint values adjusted - assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout, 1); - assertEq(lPool.maxMint(self), firstTrancheTokenPayout); - - // deposit price should be ~1.2*10**18 - (, uint256 depositPrice,,,,) = investmentManager.orderbook(address(lPool), self); - assertEq(depositPrice, 1200000000000000000); - - // trigger executed collectInvest of the second 50% at a price of 1.4 - currencyPayout = 50000000; // 50 * 10**6 - uint128 secondTrancheTokenPayout = 35714285714285714285; // 50 * 10**18 / 1.4, rounded down - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout, 0 - ); - - // collect the tranche tokens - lPool.mint(firstTrancheTokenPayout + secondTrancheTokenPayout, self); - assertEq(lPool.balanceOf(self), firstTrancheTokenPayout + secondTrancheTokenPayout); - - // redeem - lPool.requestRedeem(firstTrancheTokenPayout + secondTrancheTokenPayout); - - // trigger executed collectRedeem at a price of 1.5 - // 50% invested at 1.2 and 50% invested at 1.4 leads to ~77 tranche tokens - // when redeeming at a price of 1.5, this leads to ~115.5 currency - currencyPayout = 115500000; // 115.5*10**6 - - // mint interest into escrow - currency.mint(address(escrow), currencyPayout - investmentAmount); - - centrifugeChain.isExecutedCollectRedeem( - poolId, - trancheId, - bytes32(bytes20(self)), - _currencyId, - currencyPayout, - firstTrancheTokenPayout + secondTrancheTokenPayout, - 0 - ); - - // redeem price should now be ~1.5*10**18. - (,,, uint256 redeemPrice,,) = investmentManager.orderbook(address(lPool), self); - assertEq(redeemPrice, 1492615384615384615); - - // collect the currency - lPool.withdraw(currencyPayout, self, self); - assertEq(currency.balanceOf(self), currencyPayout); - } - - function testDepositAndRedeemPrecisionWithInverseDecimals(uint64 poolId, bytes16 trancheId, uint128 currencyId) - public - { - vm.assume(currencyId > 0); - - // uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like DAI - // uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like USDC - - ERC20 currency = _newErc20("Currency", "CR", 18); - address lPool_ = deployLiquidityPool(poolId, 6, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000000000000000000000000); - - // invest - uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest of the first 50% at a price of 1.2 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 currencyPayout = 50000000000000000000; // 50 * 10**18 - uint128 firstTrancheTokenPayout = 41666666; // 50 * 10**6 / 1.2, rounded down - centrifugeChain.isExecutedCollectInvest( - poolId, - trancheId, - bytes32(bytes20(self)), - _currencyId, - currencyPayout, - firstTrancheTokenPayout, - currencyPayout / 2 - ); - - // assert deposit & mint values adjusted - assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout, 10); - assertEq(lPool.maxMint(self), firstTrancheTokenPayout); - - // deposit price should be ~1.2*10**18 - (, uint256 depositPrice,,,,) = investmentManager.orderbook(address(lPool), self); - assertEq(depositPrice, 1200000019200000307); - - // trigger executed collectInvest of the second 50% at a price of 1.4 - currencyPayout = 50000000000000000000; // 50 * 10**18 - uint128 secondTrancheTokenPayout = 35714285; // 50 * 10**6 / 1.4, rounded down - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout, 0 - ); - - // collect the tranche tokens - lPool.mint(firstTrancheTokenPayout + secondTrancheTokenPayout, self); - assertEq(lPool.balanceOf(self), firstTrancheTokenPayout + secondTrancheTokenPayout); - - // redeem - lPool.requestRedeem(firstTrancheTokenPayout + secondTrancheTokenPayout); - - // trigger executed collectRedeem at a price of 1.5 - // 50% invested at 1.2 and 50% invested at 1.4 leads to ~77 tranche tokens - // when redeeming at a price of 1.5, this leads to ~115.5 currency - currencyPayout = 115500000000000000000; // 115.5*10**18 - - // mint interest into escrow - currency.mint(address(escrow), currencyPayout - investmentAmount); - - centrifugeChain.isExecutedCollectRedeem( - poolId, - trancheId, - bytes32(bytes20(self)), - _currencyId, - currencyPayout, - firstTrancheTokenPayout + secondTrancheTokenPayout, - 0 - ); - - // redeem price should now be ~1.5*10**18. - (,,, uint256 redeemPrice,,) = investmentManager.orderbook(address(lPool), self); - assertEq(redeemPrice, 1492615411252828877); - - // collect the currency - lPool.withdraw(currencyPayout, self, self); - assertEq(currency.balanceOf(self), currencyPayout); - } - - // Test that assumes the swap from usdc (investment currency) to dai (pool currency) has a cost of 1% - function testDepositAndRedeemPrecisionWithSlippage(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { - vm.assume(currencyId > 0); - - uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC - uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI - - ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); - address lPool_ = - deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - - // price = (100*10**18) / (99 * 10**18) = 101.010101 * 10**18 - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1010101010101010101); - - // invest - uint256 investmentAmount = 100000000; // 100 * 10**6 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest at a tranche token price of 1.2 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 currencyPayout = 99000000; // 99 * 10**6 - - // invested amount in dai is 99 * 10**18 - // executed at price of 1.2, leads to a tranche token payout of - // 99 * 10**18 / 1.2 = 82500000000000000000 - uint128 trancheTokenPayout = 82500000000000000000; - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, trancheTokenPayout, 0 - ); - - // assert deposit & mint values adjusted - assertEq(lPool.maxDeposit(self), currencyPayout); - assertEq(lPool.maxMint(self), trancheTokenPayout); - - // lp price is value of 1 tranche token in dai - assertEq(lPool.latestPrice(), 1200000000000000000); - - // lp price is set to the deposit price - (, uint256 depositPrice,,,,) = investmentManager.orderbook(address(lPool), self); - assertEq(depositPrice, 1200000000000000000); - } - - // Test that assumes the swap from usdc (investment currency) to dai (pool currency) has a cost of 1% - function testDepositAndRedeemPrecisionWithSlippageAndWithInverseDecimal( - uint64 poolId, - bytes16 trancheId, - uint128 currencyId - ) public { - vm.assume(currencyId > 0); - - uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like DAI - uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like USDC - - ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); - address lPool_ = - deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - - // price = (100*10**18) / (99 * 10**18) = 101.010101 * 10**18 - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1010101010101010101); - - // invest - uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest at a tranche token price of 1.2 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 currencyPayout = 99000000000000000000; // 99 * 10**18 - - // invested amount in dai is 99 * 10**18 - // executed at price of 1.2, leads to a tranche token payout of - // 99 * 10**6 / 1.2 = 82500000 - uint128 trancheTokenPayout = 82500000; - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, trancheTokenPayout, 0 - ); - - // assert deposit & mint values adjusted - assertEq(lPool.maxDeposit(self), currencyPayout); - assertEq(lPool.maxMint(self), trancheTokenPayout); - // lp price is value of 1 tranche token in usdc - assertEq(lPool.latestPrice(), 1200000000000000000); - - // lp price is set to the deposit price - (, uint256 depositPrice,,,,) = investmentManager.orderbook(address(lPool), self); - assertEq(depositPrice, 1200000000000000000); - } - - function testAssetShareConversion(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { - vm.assume(currencyId > 0); - - uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC - uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI - - ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); - address lPool_ = - deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000); - - // invest - uint256 investmentAmount = 100000000; // 100 * 10**6 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest at a price of 1.0 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 trancheTokenPayout = 100000000000000000000; // 100 * 10**18 - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, uint128(investmentAmount), trancheTokenPayout, 0 - ); - lPool.mint(trancheTokenPayout, self); - - // assert share/asset conversion - assertEq(lPool.latestPrice(), 1000000000000000000); - assertEq(lPool.totalSupply(), 100000000000000000000); - assertEq(lPool.totalAssets(), 100000000); - assertEq(lPool.convertToShares(100000000), 100000000000000000000); // tranche tokens have 12 more decimals than assets - assertEq(lPool.convertToAssets(lPool.convertToShares(100000000000000000000)), 100000000000000000000); - - // assert share/asset conversion after price update - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1200000000000000000); - - assertEq(lPool.latestPrice(), 1200000000000000000); - assertEq(lPool.totalAssets(), 120000000); - assertEq(lPool.convertToShares(120000000), 100000000000000000000); // tranche tokens have 12 more decimals than assets - assertEq(lPool.convertToAssets(lPool.convertToShares(120000000000000000000)), 120000000000000000000); - } - - function testAssetShareConversionWithInverseDecimals(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { - vm.assume(currencyId > 0); - - uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like DAI - uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like USDC - - ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); - address lPool_ = - deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000); - - // invest - uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 - centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); - currency.approve(address(investmentManager), investmentAmount); - currency.mint(self, investmentAmount); - lPool.requestDeposit(investmentAmount); - - // trigger executed collectInvest at a price of 1.0 - uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId - uint128 trancheTokenPayout = 100000000; // 100 * 10**6 - centrifugeChain.isExecutedCollectInvest( - poolId, trancheId, bytes32(bytes20(self)), _currencyId, uint128(investmentAmount), trancheTokenPayout, 0 - ); - lPool.mint(trancheTokenPayout, self); - - // assert share/asset conversion - assertEq(lPool.latestPrice(), 1000000000000000000); - assertEq(lPool.totalSupply(), 100000000); - assertEq(lPool.totalAssets(), 100000000000000000000); - assertEq(lPool.convertToShares(100000000000000000000), 100000000); // tranche tokens have 12 less decimals than assets - assertEq(lPool.convertToAssets(lPool.convertToShares(100000000000000000000)), 100000000000000000000); - - // assert share/asset conversion after price update - centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1200000000000000000); - - assertEq(lPool.latestPrice(), 1200000000000000000); - assertEq(lPool.totalAssets(), 120000000000000000000); - assertEq(lPool.convertToShares(120000000000000000000), 100000000); // tranche tokens have 12 less decimals than assets - assertEq(lPool.convertToAssets(lPool.convertToShares(120000000000000000000)), 120000000000000000000); - } - - function testCancelDepositOrder(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - uint128 price = 2 * 10 ** 18; - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price); - erc20.mint(self, amount); - erc20.approve(address(investmentManager), amount); // add allowance - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); - - lPool.requestDeposit(amount); - - assertEq(erc20.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(address(self)), 0); - - // check message was send out to centchain - lPool.cancelDepositRequest(); - bytes memory cancelOrderMessage = Messages.formatCancelInvestOrder( - lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId - ); - assertEq(cancelOrderMessage, router.values_bytes("send")); - - centrifugeChain.isExecutedDecreaseInvestOrder( - lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId, uint128(amount), 0 - ); - assertEq(erc20.balanceOf(address(escrow)), 0); - assertEq(erc20.balanceOf(address(userEscrow)), amount); - assertEq(erc20.balanceOf(self), 0); - assertEq(lPool.maxRedeem(self), amount); - assertEq(lPool.maxWithdraw(self), amount); - } - - function testDepositMint(uint256 amount) public { - // If lower than 4 or odd, rounding down can lead to not receiving any tokens - amount = uint128(bound(amount, 4, MAX_UINT128)); - vm.assume(amount % 2 == 0); - - uint128 price = 2 * 10 ** 18; - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price); - - erc20.mint(self, amount); - - // will fail - user not member: can not receive trancheToken - vm.expectRevert(bytes("InvestmentManager/transfer-not-allowed")); - lPool.requestDeposit(amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member - - // // will fail - user did not give currency allowance to investmentManager - vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); - lPool.requestDeposit(amount); - - // success - erc20.approve(address(investmentManager), amount); // add allowance - lPool.requestDeposit(amount); - - // fail: no currency left - vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); - lPool.requestDeposit(amount); - - // ensure funds are locked in escrow - assertEq(erc20.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(self), 0); - assertEq(lPool.userDepositRequest(self), amount); - - // trigger executed collectInvest - uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 trancheTokensPayout = uint128((amount * 10 ** 18) / price); // trancheTokenPrice = 2$ - assertApproxEqAbs(trancheTokensPayout, amount / 2, 2); - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(self)), - _currencyId, - uint128(amount), - trancheTokensPayout, - 0 - ); - - // assert deposit & mint values adjusted - assertEq(lPool.maxMint(self), trancheTokensPayout); - assertApproxEqAbs(lPool.maxDeposit(self), amount, 1); - assertEq(lPool.userDepositRequest(self), 0); - // assert tranche tokens minted - assertEq(lPool.balanceOf(address(escrow)), trancheTokensPayout); - // assert conversions - assertEq(lPool.previewDeposit(amount), trancheTokensPayout); - assertApproxEqAbs(lPool.previewMint(trancheTokensPayout), amount, 1); - - // deposit 50% of the amount - uint256 share = 2; - lPool.deposit(amount / share, self); // mint half the amount - - // Allow 2 difference because of rounding - assertApproxEqAbs(lPool.balanceOf(self), trancheTokensPayout / share, 2); - assertApproxEqAbs(lPool.balanceOf(address(escrow)), trancheTokensPayout - trancheTokensPayout / share, 2); - assertApproxEqAbs(lPool.maxMint(self), trancheTokensPayout - trancheTokensPayout / share, 2); - assertApproxEqAbs(lPool.maxDeposit(self), amount - amount / share, 2); - - // mint the rest - lPool.mint(lPool.maxMint(self), self); - assertEq(lPool.balanceOf(self), trancheTokensPayout - lPool.maxMint(self)); - assertTrue(lPool.balanceOf(address(escrow)) <= 1); - assertTrue(lPool.maxMint(self) <= 1); - - // remainder is rounding difference - assertTrue(lPool.maxDeposit(self) <= amount * 0.01e18); - } - - function testDepositFairRounding(uint256 totalAmount, uint256 tokenAmount) public { - totalAmount = bound(totalAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); - tokenAmount = bound(tokenAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); - - //Deploy a pool - LiquidityPool lPool = LiquidityPool(deploySimplePool()); - TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); - - root.relyContract(address(trancheToken), self); - trancheToken.mint(address(escrow), type(uint128).max); // mint buffer to the escrow. Mock funds from other users - - // fund user & request deposit - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, uint64(block.timestamp)); - erc20.mint(self, totalAmount); - erc20.approve(address(investmentManager), totalAmount); - lPool.requestDeposit(totalAmount); - - // Ensure funds were locked in escrow - assertEq(erc20.balanceOf(address(escrow)), totalAmount); - assertEq(erc20.balanceOf(self), 0); - - // Gateway returns randomly generated values for amount of tranche tokens and currency - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(self)), - defaultCurrencyId, - uint128(totalAmount), - uint128(tokenAmount), - 0 - ); - - // user claims multiple partial deposits - vm.assume(lPool.maxDeposit(self) > 0); - assertEq(erc20.balanceOf(self), 0); - while (lPool.maxDeposit(self) > 0) { - uint256 randomDeposit = random(lPool.maxDeposit(self), 1); - - try lPool.deposit(randomDeposit, self) { - if (lPool.maxDeposit(self) == 0 && lPool.maxMint(self) > 0) { - // If you cannot deposit anymore because the 1 wei remaining is rounded down, - // you should mint the remainder instead. - lPool.mint(lPool.maxMint(self), self); - break; - } - } catch { - // If you cannot deposit anymore because the 1 wei remaining is rounded down, - // you should mint the remainder instead. - lPool.mint(lPool.maxMint(self), self); - break; - } - } - - assertEq(lPool.maxDeposit(self), 0); - assertApproxEqAbs(lPool.balanceOf(self), tokenAmount, 1); - } - - function testMintFairRounding(uint256 totalAmount, uint256 tokenAmount) public { - totalAmount = bound(totalAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); - tokenAmount = bound(tokenAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); - - //Deploy a pool - LiquidityPool lPool = LiquidityPool(deploySimplePool()); - TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); - - root.relyContract(address(trancheToken), self); - trancheToken.mint(address(escrow), type(uint128).max); // mint buffer to the escrow. Mock funds from other users - - // fund user & request deposit - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, uint64(block.timestamp)); - erc20.mint(self, totalAmount); - erc20.approve(address(investmentManager), totalAmount); - lPool.requestDeposit(totalAmount); - - // Ensure funds were locked in escrow - assertEq(erc20.balanceOf(address(escrow)), totalAmount); - assertEq(erc20.balanceOf(self), 0); - - // Gateway returns randomly generated values for amount of tranche tokens and currency - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(self)), - defaultCurrencyId, - uint128(totalAmount), - uint128(tokenAmount), - 0 - ); - - // user claims multiple partial mints - uint256 i = 0; - while (lPool.maxMint(self) > 0) { - uint256 randomMint = random(lPool.maxMint(self), i); - try lPool.mint(randomMint, self) { - i++; - } catch { - break; - } - } - - assertEq(lPool.maxMint(self), 0); - assertLe(lPool.balanceOf(self), tokenAmount); - } - - function testDepositMintToReceiver(uint256 amount, address receiver) public { - // If lower than 4 or odd, rounding down can lead to not receiving any tokens - amount = uint128(bound(amount, 4, MAX_UINT128)); - vm.assume(amount % 2 == 0); - - uint128 price = 2 * 10 ** 18; - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - vm.assume(addressAssumption(receiver)); - - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price); - - erc20.mint(self, amount); - - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member - erc20.approve(address(investmentManager), amount); // add allowance - lPool.requestDeposit(amount); - - // trigger executed collectInvest - uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 trancheTokensPayout = uint128(amount * 10 ** 18 / price); // trancheTokenPrice = 2$ - assertApproxEqAbs(trancheTokensPayout, amount / 2, 2); - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(self)), - _currencyId, - uint128(amount), - trancheTokensPayout, - 0 - ); - - // assert deposit & mint values adjusted - assertEq(lPool.maxMint(self), trancheTokensPayout); // max deposit - assertEq(lPool.maxDeposit(self), amount); // max deposit - // assert tranche tokens minted - assertEq(lPool.balanceOf(address(escrow)), trancheTokensPayout); - // assert conversions - assertEq(lPool.previewDeposit(amount), trancheTokensPayout); - assertApproxEqAbs(lPool.previewMint(trancheTokensPayout), amount, 1); - - // deposit 1/2 funds to receiver - vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); - lPool.deposit(amount / 2, receiver); // mint half the amount - - vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); - lPool.mint(amount / 2, receiver); // mint half the amount - - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), receiver, type(uint64).max); // add receiver member - - // success - lPool.deposit(amount / 2, receiver); // mint half the amount - lPool.mint(lPool.maxMint(self), receiver); // mint half the amount - - assertApproxEqAbs(lPool.balanceOf(receiver), trancheTokensPayout, 1); - assertApproxEqAbs(lPool.balanceOf(receiver), trancheTokensPayout, 1); - assertApproxEqAbs(lPool.balanceOf(address(escrow)), 0, 1); - assertApproxEqAbs(erc20.balanceOf(address(escrow)), amount, 1); - } - - function testDepositWithPermitFR(uint256 amount, address random_) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - vm.assume(addressAssumption(random_)); - - // Use a wallet with a known private key so we can sign the permit message - address investor = vm.addr(0xABCD); - vm.prank(vm.addr(0xABCD)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - erc20.mint(investor, amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); - - // Sign permit for depositing investment currency - (uint8 v, bytes32 r, bytes32 s) = vm.sign( - 0xABCD, - keccak256( - abi.encodePacked( - "\x19\x01", - erc20.DOMAIN_SEPARATOR(), - keccak256( - abi.encode( - erc20.PERMIT_TYPEHASH(), investor, address(investmentManager), amount, 0, block.timestamp - ) - ) - ) - ) - ); - - vm.prank(random_); // random fr permit - erc20.permit(investor, address(investmentManager), amount, block.timestamp, v, r, s); - - // investor still able to requestDepositWithPermit - lPool.requestDepositWithPermit(amount, investor, block.timestamp, v, r, s); - - // ensure funds are locked in escrow - assertEq(erc20.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(investor), 0); - } - - function testDepositWithPermit(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - // Use a wallet with a known private key so we can sign the permit message - address investor = vm.addr(0xABCD); - vm.prank(vm.addr(0xABCD)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - erc20.mint(investor, amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); - - // Sign permit for depositing investment currency - (uint8 v, bytes32 r, bytes32 s) = vm.sign( - 0xABCD, - keccak256( - abi.encodePacked( - "\x19\x01", - erc20.DOMAIN_SEPARATOR(), - keccak256( - abi.encode( - erc20.PERMIT_TYPEHASH(), investor, address(investmentManager), amount, 0, block.timestamp - ) - ) - ) - ) - ); - - lPool.requestDepositWithPermit(amount, investor, block.timestamp, v, r, s); - // To avoid stack too deep errors - delete v; - delete r; - delete s; - - // ensure funds are locked in escrow - assertEq(erc20.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(investor), 0); - - // collect 50% of the tranche tokens - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(investor)), - poolManager.currencyAddressToId(address(erc20)), - uint128(amount), - uint128(amount), - 0 - ); - - uint256 maxMint = lPool.maxMint(investor); - vm.prank(vm.addr(0xABCD)); - lPool.mint(maxMint, investor); - - TrancheToken trancheToken = TrancheToken(address(lPool.share())); - assertEq(trancheToken.balanceOf(address(investor)), maxMint); - } - - function testRedeem(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - deposit(lPool_, self, amount); // deposit funds first - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); - - // success - lPool.requestRedeem(amount); - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(lPool.userRedeemRequest(self), amount); - - // fail: no tokens left - vm.expectRevert(bytes("ERC20/insufficient-balance")); - lPool.requestRedeem(amount); - - // trigger executed collectRedeem - uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 currencyPayout = uint128(amount) / uint128(defaultPrice); - centrifugeChain.isExecutedCollectRedeem( - lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), _currencyId, currencyPayout, uint128(amount), 0 - ); - - // assert withdraw & redeem values adjusted - assertEq(lPool.maxWithdraw(self), currencyPayout); // max deposit - assertEq(lPool.maxRedeem(self), amount); // max deposit - assertEq(lPool.userRedeemRequest(self), 0); - assertEq(lPool.balanceOf(address(escrow)), 0); - assertEq(erc20.balanceOf(address(userEscrow)), currencyPayout); - // assert conversions - assertEq(lPool.previewWithdraw(currencyPayout), amount); - assertEq(lPool.previewRedeem(amount), currencyPayout); - - // success - lPool.redeem(amount / 2, self, self); // redeem half the amount to own wallet - - // fail -> investor has no approval to receive funds - vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); - lPool.redeem(amount / 2, investor, self); // redeem half the amount to another wallet - - // fail -> receiver needs to have max approval - erc20.approve(investor, lPool.maxRedeem(self)); - vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); - lPool.redeem(amount / 2, investor, self); // redeem half the amount to investor wallet - - // success - erc20.approve(investor, type(uint256).max); - lPool.redeem(amount / 2, investor, self); // redeem half the amount to investor wallet - - assertEq(lPool.balanceOf(self), 0); - assertTrue(lPool.balanceOf(address(escrow)) <= 1); - assertTrue(erc20.balanceOf(address(userEscrow)) <= 1); - - assertApproxEqAbs(erc20.balanceOf(self), (amount / 2), 1); - assertApproxEqAbs(erc20.balanceOf(investor), (amount / 2), 1); - assertTrue(lPool.maxWithdraw(self) <= 1); - assertTrue(lPool.maxRedeem(self) <= 1); - } - - function testCancelRedeemOrder(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - deposit(lPool_, self, amount); // deposit funds first - - lPool.requestRedeem(amount); - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(lPool.balanceOf(self), 0); - - // check message was send out to centchain - lPool.cancelRedeemRequest(); - bytes memory cancelOrderMessage = Messages.formatCancelRedeemOrder( - lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId - ); - assertEq(cancelOrderMessage, router.values_bytes("send")); - - centrifugeChain.isExecutedDecreaseRedeemOrder( - lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId, uint128(amount), 0 - ); - - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(lPool.balanceOf(self), 0); - assertEq(lPool.maxDeposit(self), amount); - assertEq(lPool.maxMint(self), amount); - } - - function testWithdraw(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - - deposit(lPool_, self, amount); // deposit funds first - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); - - // will fail - user did not give tranche token allowance to investmentManager - vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); - lPool.requestDeposit(amount); - - lPool.requestRedeem(amount); - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(address(userEscrow)), 0); - - // trigger executed collectRedeem - uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - uint128 currencyPayout = uint128(amount) / defaultPrice; - centrifugeChain.isExecutedCollectRedeem( - lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), _currencyId, currencyPayout, uint128(amount), 0 - ); - - // assert withdraw & redeem values adjusted - assertEq(lPool.maxWithdraw(self), currencyPayout); // max deposit - assertEq(lPool.maxRedeem(self), amount); // max deposit - assertEq(lPool.balanceOf(address(escrow)), 0); - assertEq(erc20.balanceOf(address(userEscrow)), currencyPayout); - - lPool.withdraw(amount / 2, self, self); // withdraw half teh amount - - // fail -> investor has no approval to receive funds - vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); - lPool.withdraw(amount / 2, investor, self); // redeem half the amount to another wallet - - // fail -> receiver needs to have max approval - erc20.approve(investor, lPool.maxWithdraw(self)); - vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); - lPool.withdraw(amount / 2, investor, self); // redeem half the amount to investor wallet - - // success - erc20.approve(investor, type(uint256).max); - lPool.withdraw(amount / 2, investor, self); // redeem half the amount to investor wallet - - assertTrue(lPool.balanceOf(self) <= 1); - assertTrue(erc20.balanceOf(address(userEscrow)) <= 1); - assertApproxEqAbs(erc20.balanceOf(self), currencyPayout / 2, 1); - assertApproxEqAbs(erc20.balanceOf(investor), currencyPayout / 2, 1); - assertTrue(lPool.maxRedeem(self) <= 1); - assertTrue(lPool.maxWithdraw(self) <= 1); - } - - function testDecreaseDepositRequest(uint256 amount, uint256 decreaseAmount) public { - decreaseAmount = uint128(bound(decreaseAmount, 2, MAX_UINT128 - 1)); - amount = uint128(bound(amount, decreaseAmount + 1, MAX_UINT128)); // amount > decreaseAmount - uint128 price = 2 * 10 ** 18; - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price); - - erc20.mint(self, amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member - erc20.approve(address(investmentManager), amount); // add allowance - lPool.requestDeposit(amount); - - assertEq(erc20.balanceOf(address(escrow)), amount); - assertEq(erc20.balanceOf(self), 0); - - // decrease deposit request - lPool.decreaseDepositRequest(decreaseAmount); - centrifugeChain.isExecutedDecreaseInvestOrder( - lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), defaultCurrencyId, uint128(decreaseAmount), 0 - ); - - assertEq(erc20.balanceOf(address(escrow)), amount - decreaseAmount); - assertEq(erc20.balanceOf(address(userEscrow)), decreaseAmount); - assertEq(erc20.balanceOf(self), 0); - assertEq(lPool.maxWithdraw(self), decreaseAmount); - assertEq(lPool.maxRedeem(self), decreaseAmount); - } - - function testDecreaseRedeemRequest(uint256 amount, uint256 decreaseAmount) public { - decreaseAmount = uint128(bound(decreaseAmount, 2, MAX_UINT128 - 1)); - amount = uint128(bound(amount, decreaseAmount + 1, MAX_UINT128)); // amount > decreaseAmount - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - centrifugeChain.updateTrancheTokenPrice(lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice); - deposit(lPool_, self, amount); - lPool.requestRedeem(amount); - - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(lPool.balanceOf(self), 0); - - // decrease redeem request - lPool.decreaseRedeemRequest(decreaseAmount); - centrifugeChain.isExecutedDecreaseRedeemOrder( - lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), defaultCurrencyId, uint128(decreaseAmount), 0 - ); - - assertEq(lPool.balanceOf(address(escrow)), amount); - assertEq(lPool.balanceOf(self), 0); - assertEq(lPool.maxDeposit(self), decreaseAmount); - assertEq(lPool.maxMint(self), decreaseAmount); - } - - function testTriggerIncreaseRedeemOrder(uint256 amount) public { - amount = uint128(bound(amount, 2, MAX_UINT128)); - - address lPool_ = deploySimplePool(); - LiquidityPool lPool = LiquidityPool(lPool_); - deposit(lPool_, investor, amount); // deposit funds first - uint256 investorBalanceBefore = erc20.balanceOf(investor); - // Trigger request redeem of half the amount - centrifugeChain.triggerIncreaseRedeemOrder( - lPool.poolId(), lPool.trancheId(), investor, defaultCurrencyId, uint128(amount / 2) - ); - - assertApproxEqAbs(lPool.balanceOf(address(escrow)), amount / 2, 1); - assertApproxEqAbs(lPool.balanceOf(investor), amount / 2, 1); - - centrifugeChain.isExecutedCollectRedeem( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(investor)), - defaultCurrencyId, - uint128(amount / 2), - uint128(amount / 2), - uint128(amount / 2) - ); - - assertApproxEqAbs(lPool.balanceOf(address(escrow)), 0, 1); - assertApproxEqAbs(erc20.balanceOf(address(userEscrow)), amount / 2, 1); - - vm.prank(investor); - lPool.redeem(amount / 2, investor, investor); - - assertApproxEqAbs(erc20.balanceOf(investor), investorBalanceBefore + amount / 2, 1); - } - - // helpers - function deposit(address _lPool, address investor, uint256 amount) public { - LiquidityPool lPool = LiquidityPool(_lPool); - erc20.mint(investor, amount); - centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); // add user as member - vm.prank(investor); - erc20.approve(address(investmentManager), amount); // add allowance - - vm.prank(investor); - lPool.requestDeposit(amount); - // trigger executed collectInvest - uint128 currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId - centrifugeChain.isExecutedCollectInvest( - lPool.poolId(), - lPool.trancheId(), - bytes32(bytes20(investor)), - currencyId, - uint128(amount), - uint128(amount), - 0 - ); - - vm.prank(investor); - lPool.deposit(amount, investor); // withdraw the amount - } - - function amountAssumption(uint256 amount) public pure returns (bool) { - return (amount > 1 && amount < MAX_UINT128); - } - - function addressAssumption(address user) public view returns (bool) { - return (user != address(0) && user != address(erc20) && user.code.length == 0); - } - - function random(uint256 maxValue, uint256 nonce) internal view returns (uint256) { - if (maxValue == 1) { - return maxValue; - } - uint256 randomnumber = uint256(keccak256(abi.encodePacked(block.timestamp, self, nonce))) % (maxValue - 1); - return randomnumber + 1; - } } diff --git a/test/PoolManager.t.sol b/test/PoolManager.t.sol index 5747f1be..23a50a45 100644 --- a/test/PoolManager.t.sol +++ b/test/PoolManager.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.21; import "./TestSetup.t.sol"; contract PoolManagerTest is TestSetup { + // Deployment function testDeployment() public { // values set correctly @@ -21,6 +22,101 @@ contract PoolManagerTest is TestSetup { // assertEq(poolManager.wards(self), 0); // deployer has no permissions -> not possible within tests } + function testFile() public { + address newGateway = makeAddr("newGateway"); + poolManager.file("gateway", newGateway); + assertEq(address(poolManager.gateway()), newGateway); + + address newInvestmentManager = makeAddr("newInvestmentManager"); + poolManager.file("investmentManager", newInvestmentManager); + assertEq(address(poolManager.investmentManager()), newInvestmentManager); + + address newRestrictionManagerFactory = makeAddr("newRestrictionManagerFactory"); + poolManager.file("restrictionManagerFactory", newRestrictionManagerFactory); + assertEq(address(poolManager.restrictionManagerFactory()), newRestrictionManagerFactory); + + address newEscrow = makeAddr("newEscrow"); + vm.expectRevert("PoolManager/file-unrecognized-param"); + poolManager.file("escrow", newEscrow); + } + + function testAddPool(uint64 poolId) public { + centrifugeChain.addPool(poolId); + (uint256 createdAt) = poolManager.pools(poolId); + assertEq(createdAt, block.timestamp); + + vm.expectRevert(bytes("PoolManager/pool-already-added")); + centrifugeChain.addPool(poolId); + + vm.expectRevert(bytes("PoolManager/not-the-gateway")); + poolManager.addPool(poolId); + } + + function testAddTranche( + uint64 poolId, + bytes16 trancheId, + string memory tokenName, + string memory tokenSymbol, + uint8 decimals + ) public { + decimals = uint8(bound(decimals, 1, 18)); + + vm.expectRevert(bytes("PoolManager/invalid-pool")); + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + centrifugeChain.addPool(poolId); + + vm.expectRevert(bytes("PoolManager/not-the-gateway")); + poolManager.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + + vm.expectRevert(bytes("PoolManager/too-many-tranche-token-decimals")); + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, 19); + + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + + vm.expectRevert(bytes("PoolManager/tranche-already-exists")); // check why no revert + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + + poolManager.deployTranche(poolId, trancheId); + + TrancheToken trancheToken = TrancheToken(poolManager.getTrancheToken(poolId, trancheId)); + + assertEq( + _bytes128ToString(_stringToBytes128(tokenName)), _bytes128ToString(_stringToBytes128(trancheToken.name())) + ); + assertEq( + _bytes32ToString(_stringToBytes32(tokenSymbol)), _bytes32ToString(_stringToBytes32(trancheToken.symbol())) + ); + assertEq(decimals, trancheToken.decimals()); + + vm.expectRevert(bytes("PoolManager/tranche-already-deployed")); // comment back in, once reviews merged + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + } + + function testAddMultipleTranchesWorks( + uint64 poolId, + bytes16[4] calldata trancheIds, + string memory tokenName, + string memory tokenSymbol, + uint8 decimals + ) public { + decimals = uint8(bound(decimals, 1, 18)); + vm.assume(!hasDuplicates(trancheIds)); + centrifugeChain.addPool(poolId); + + for (uint256 i = 0; i < trancheIds.length; i++) { + centrifugeChain.addTranche(poolId, trancheIds[i], tokenName, tokenSymbol, decimals); + poolManager.deployTranche(poolId, trancheIds[i]); + TrancheToken trancheToken = TrancheToken(poolManager.getTrancheToken(poolId, trancheIds[i])); + assertEq( + _bytes128ToString(_stringToBytes128(tokenName)), _bytes128ToString(_stringToBytes128(trancheToken.name())) + ); + assertEq( + _bytes32ToString(_stringToBytes32(tokenSymbol)), _bytes32ToString(_stringToBytes32(trancheToken.symbol())) + ); + assertEq(decimals, trancheToken.decimals()); + } + } + function testDeployTranche( uint64 poolId, uint8 decimals, @@ -48,89 +144,107 @@ contract PoolManagerTest is TestSetup { ); } - function testAddCurrencyWorks(uint128 currency, uint128 badCurrency) public { + function testAddCurrency(uint128 currency) public { + uint128 badCurrency = 2; vm.assume(currency > 0); - vm.assume(badCurrency > 0); vm.assume(currency != badCurrency); + ERC20 erc20_invalid = _newErc20("X's Dollar", "USDX", 42); + + vm.expectRevert(bytes("PoolManager/too-many-currency-decimals")); + centrifugeChain.addCurrency(currency, address(erc20_invalid)); centrifugeChain.addCurrency(currency, address(erc20)); - (address address_) = poolManager.currencyIdToAddress(currency); - assertEq(address_, address(erc20)); // Verify we can't override the same currency id another address - ERC20 badErc20 = _newErc20("BadActor's Dollar", "BADUSD", 17); vm.expectRevert(bytes("PoolManager/currency-id-in-use")); - centrifugeChain.addCurrency(currency, address(badErc20)); - assertEq(poolManager.currencyIdToAddress(currency), address(erc20)); - + centrifugeChain.addCurrency(currency, makeAddr("randomCurrency")); + // Verify we can't add a currency address that already exists associated with a different currency id vm.expectRevert(bytes("PoolManager/currency-address-in-use")); centrifugeChain.addCurrency(badCurrency, address(erc20)); - assertEq(poolManager.currencyIdToAddress(currency), address(erc20)); - } - - function testAddCurrencyHasMaxDecimals() public { - ERC20 erc20_invalid = _newErc20("X's Dollar", "USDX", 42); - vm.expectRevert(bytes("PoolManager/too-many-currency-decimals")); - centrifugeChain.addCurrency(1, address(erc20_invalid)); - ERC20 erc20_valid = _newErc20("X's Dollar", "USDX", 17); - centrifugeChain.addCurrency(2, address(erc20_valid)); - - ERC20 erc20_valid2 = _newErc20("X's Dollar", "USDX", 6); - centrifugeChain.addCurrency(3, address(erc20_valid2)); + assertEq(poolManager.currencyIdToAddress(currency), address(erc20)); } - function testIncomingTransferWithoutEscrowFundsFails( + function testDeployLiquidityPool( + uint64 poolId, + uint8 decimals, string memory tokenName, string memory tokenSymbol, - uint8 decimals, - uint128 currency, - bytes32 sender, - address recipient, - uint128 amount + bytes16 trancheId, + uint128 currency ) public { decimals = uint8(bound(decimals, 1, 18)); vm.assume(currency > 0); - vm.assume(amount > 0); - vm.assume(recipient != address(0)); - - ERC20 erc20 = _newErc20(tokenName, tokenSymbol, decimals); - vm.assume(recipient.code.length == 0); + centrifugeChain.addPool(poolId); // add pool + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche centrifugeChain.addCurrency(currency, address(erc20)); + + vm.expectRevert(bytes("PoolManager/tranche-does-not-exist")); + poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + address trancheToken_ = poolManager.deployTranche(poolId, trancheId); - assertEq(erc20.balanceOf(address(poolManager.escrow())), 0); - vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); - centrifugeChain.incomingTransfer(currency, sender, bytes32(bytes20(recipient)), amount); - assertEq(erc20.balanceOf(address(poolManager.escrow())), 0); - assertEq(erc20.balanceOf(recipient), 0); + vm.expectRevert(bytes("PoolManager/currency-not-supported")); + poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + centrifugeChain.allowInvestmentCurrency(poolId, currency); + + address lPoolAddress = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + address lPool_ = poolManager.getLiquidityPool(poolId, trancheId, address(erc20)); // make sure the pool was stored in LP + + vm.expectRevert(bytes("PoolManager/liquidity-pool-already-deployed")); + poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + + // make sure the pool was added to the tranche struct + assertEq(lPoolAddress, lPool_); + + // check LiquidityPool state + LiquidityPool lPool = LiquidityPool(lPool_); + TrancheToken trancheToken = TrancheToken(trancheToken_); + assertEq(address(lPool.manager()), address(investmentManager)); + assertEq(lPool.asset(), address(erc20)); + assertEq(lPool.poolId(), poolId); + assertEq(lPool.trancheId(), trancheId); + assertEq(address(lPool.share()), trancheToken_); + assertTrue(lPool.wards(address(investmentManager)) == 1); + assertTrue(lPool.wards(address(this)) == 0); + assertTrue(investmentManager.wards(lPoolAddress) == 1); + + assertEq(trancheToken.name(), _bytes128ToString(_stringToBytes128(tokenName))); + assertEq(trancheToken.symbol(), _bytes32ToString(_stringToBytes32(tokenSymbol))); + assertEq(trancheToken.decimals(), decimals); + assertTrue( + RestrictionManagerLike(address(trancheToken.restrictionManager())).hasMember( + address(investmentManager.escrow()) + ) + ); + + assertTrue(trancheToken.wards(address(poolManager)) == 1); + assertTrue(trancheToken.wards(lPool_) == 1); + assertTrue(trancheToken.wards(address(this)) == 0); + assertTrue(trancheToken.isTrustedForwarder(lPool_)); // Lpool is not trusted forwarder on token } - function testIncomingTransferWorks( - string memory tokenName, - string memory tokenSymbol, - uint8 decimals, - uint128 currency, - bytes32 sender, - address recipient, + + function testIncomingTransfer( uint128 amount ) public { - decimals = uint8(bound(decimals, 1, 18)); vm.assume(amount > 0); - vm.assume(currency != 0); - vm.assume(recipient != address(0)); + uint128 currency = defaultCurrencyId; + address recipient = makeAddr("recipient"); + bytes32 sender = _addressToBytes32(makeAddr("sender")); - ERC20 erc20 = _newErc20(tokenName, tokenSymbol, decimals); - vm.assume(recipient.code.length == 0); + vm.expectRevert(bytes("PoolManager/unknown-currency")); + centrifugeChain.incomingTransfer(currency, sender, bytes32(bytes20(recipient)), amount); centrifugeChain.addCurrency(currency, address(erc20)); - // First, an outgoing transfer must take place which has funds currency of the currency moved to - // the escrow account, from which funds are moved from into the recipient on an incoming transfer. - erc20.approve(address(poolManager), type(uint256).max); - erc20.mint(address(this), amount); - poolManager.transfer(address(erc20), bytes32(bytes20(recipient)), amount); - assertEq(erc20.balanceOf(address(poolManager.escrow())), amount); + vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); + centrifugeChain.incomingTransfer(currency, sender, bytes32(bytes20(recipient)), amount); + + vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); + centrifugeChain.incomingTransfer(currency, sender, bytes32(bytes20(recipient)), amount); + + erc20.mint(address(poolManager.escrow()), amount); // fund escrow // Now we test the incoming message centrifugeChain.incomingTransfer(currency, sender, bytes32(bytes20(recipient)), amount); @@ -138,70 +252,54 @@ contract PoolManagerTest is TestSetup { assertEq(erc20.balanceOf(recipient), amount); } - // Verify that funds are moved from the msg.sender into the escrow account - function testOutgoingTransferWorks( - string memory tokenName, - string memory tokenSymbol, - uint8 decimals, + + // Verify that funds are moved from the msg.sender into the escrow account + function testOutgoingTransfer( uint128 initialBalance, - uint128 currency, - bytes32 recipient, uint128 amount ) public { - decimals = uint8(bound(decimals, 1, 18)); initialBalance = uint128(bound(initialBalance, amount, type(uint128).max)); // initialBalance >= amount vm.assume(amount > 0); - vm.assume(currency != 0); - - ERC20 erc20 = _newErc20(tokenName, tokenSymbol, decimals); - - vm.expectRevert(bytes("PoolManager/unknown-currency")); - poolManager.transfer(address(erc20), recipient, amount); - centrifugeChain.addCurrency(currency, address(erc20)); + uint128 currency = defaultCurrencyId; + bytes32 recipient = _addressToBytes32(makeAddr("recipient")); erc20.mint(address(this), initialBalance); assertEq(erc20.balanceOf(address(this)), initialBalance); assertEq(erc20.balanceOf(address(poolManager.escrow())), 0); erc20.approve(address(poolManager), type(uint256).max); + vm.expectRevert(bytes("PoolManager/unknown-currency")); + poolManager.transfer(address(erc20), recipient, amount); + centrifugeChain.addCurrency(currency, address(erc20)); + poolManager.transfer(address(erc20), recipient, amount); assertEq(erc20.balanceOf(address(this)), initialBalance - amount); assertEq(erc20.balanceOf(address(poolManager.escrow())), amount); } function testTransferTrancheTokensToCentrifuge( - uint64 validUntil, - bytes32 centChainAddress, - uint128 amount, - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency + uint128 amount ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - vm.assume(validUntil > block.timestamp + 7 days); - - address lPool_ = deployLiquidityPool(poolId, decimals, tokenName, tokenSymbol, trancheId, currency); - centrifugeChain.updateMember(poolId, trancheId, address(this), validUntil); + vm.assume(amount > 0); + uint64 validUntil = uint64(block.timestamp + 7 days); + bytes32 centChainAddress = _addressToBytes32(makeAddr("centChainAddress")); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); // fund this account with amount - centrifugeChain.incomingTransferTrancheTokens(poolId, trancheId, uint64(block.chainid), address(this), amount); - - // Verify the address(this) has the expected amount - assertEq(LiquidityPool(lPool_).balanceOf(address(this)), amount); - - // Now send the transfer from EVM -> Cent Chain + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), address(this), validUntil); + centrifugeChain.incomingTransferTrancheTokens(lPool.poolId(), lPool.trancheId(), uint64(block.chainid), address(this), amount); + assertEq(LiquidityPool(lPool_).balanceOf(address(this)), amount); // Verify the address(this) has the expected amount + + // send the transfer from EVM -> Cent Chain LiquidityPool(lPool_).approve(address(poolManager), amount); - poolManager.transferTrancheTokensToCentrifuge(poolId, trancheId, centChainAddress, amount); + poolManager.transferTrancheTokensToCentrifuge(lPool.poolId(), lPool.trancheId(), centChainAddress, amount); assertEq(LiquidityPool(lPool_).balanceOf(address(this)), 0); // Finally, verify the connector called `router.send` bytes memory message = Messages.formatTransferTrancheTokens( - poolId, - trancheId, + lPool.poolId(), + lPool.trancheId(), bytes32(bytes20(address(this))), Messages.formatDomain(Messages.Domain.Centrifuge), centChainAddress, @@ -211,565 +309,180 @@ contract PoolManagerTest is TestSetup { } function testTransferTrancheTokensFromCentrifuge( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency, - uint64 validUntil, - address destinationAddress, uint128 amount ) public { - decimals = uint8(bound(decimals, 1, 18)); - validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(destinationAddress != address(0)); - vm.assume(currency > 0); + vm.assume(amount > 0); + uint64 validUntil = uint64(block.timestamp + 7 days); + address destinationAddress = makeAddr("destinationAddress"); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); - address lPool_ = deployLiquidityPool(poolId, decimals, tokenName, tokenSymbol, trancheId, currency); - TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); - centrifugeChain.updateMember(poolId, trancheId, destinationAddress, validUntil); - assertTrue(trancheToken.checkTransferRestriction(address(0), destinationAddress, 0)); + vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); centrifugeChain.incomingTransferTrancheTokens( - poolId, trancheId, uint64(block.chainid), destinationAddress, amount + poolId, trancheId, uint64(block.chainid), destinationAddress, amount ); - assertEq(trancheToken.balanceOf(destinationAddress), amount); - } - - function testTransferTrancheTokensFromCentrifugeWithoutMemberFails( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency, - address destinationAddress, - uint128 amount - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(destinationAddress != address(0)); - vm.assume(currency > 0); - - deployLiquidityPool(poolId, decimals, tokenName, tokenSymbol, trancheId, currency); - - vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), destinationAddress, validUntil); + assertTrue(trancheToken.checkTransferRestriction(address(0), destinationAddress, 0)); centrifugeChain.incomingTransferTrancheTokens( - poolId, trancheId, uint64(block.chainid), destinationAddress, amount + poolId, trancheId, uint64(block.chainid), destinationAddress, amount ); + assertEq(trancheToken.balanceOf(destinationAddress), amount); } function testTransferTrancheTokensToEVM( - uint64 poolId, - bytes16 trancheId, - string memory tokenName, - string memory tokenSymbol, - uint8 decimals, - uint64 validUntil, - address destinationAddress, - uint128 amount, - uint128 currency + uint128 amount ) public { - decimals = uint8(bound(decimals, 1, 18)); - validUntil = uint64(bound(validUntil, block.timestamp + 7 days + 1, type(uint64).max)); - vm.assume(destinationAddress != address(0)); - vm.assume(currency > 0); + uint64 validUntil = uint64(block.timestamp + 7 days); + address destinationAddress = makeAddr("destinationAddress"); vm.assume(amount > 0); - address lPool_ = deployLiquidityPool(poolId, decimals, tokenName, tokenSymbol, trancheId, currency); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); - centrifugeChain.updateMember(poolId, trancheId, destinationAddress, validUntil); - centrifugeChain.updateMember(poolId, trancheId, address(this), validUntil); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), destinationAddress, validUntil); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), address(this), validUntil); assertTrue(trancheToken.checkTransferRestriction(address(0), address(this), 0)); assertTrue(trancheToken.checkTransferRestriction(address(0), destinationAddress, 0)); - // Fund this address with amount - centrifugeChain.incomingTransferTrancheTokens(poolId, trancheId, uint64(block.chainid), address(this), amount); + // Fund this address with samount + centrifugeChain.incomingTransferTrancheTokens(lPool.poolId(), lPool.trancheId(), uint64(block.chainid), address(this), amount); assertEq(trancheToken.balanceOf(address(this)), amount); // Approve and transfer amount from this address to destinationAddress trancheToken.approve(address(poolManager), amount); - poolManager.transferTrancheTokensToEVM(poolId, trancheId, uint64(block.chainid), destinationAddress, amount); + poolManager.transferTrancheTokensToEVM(lPool.poolId(), lPool.trancheId(), uint64(block.chainid), destinationAddress, amount); assertEq(trancheToken.balanceOf(address(this)), 0); } - - function testUpdatingMemberWorks( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - address user, + + function testUpdateMember( uint64 validUntil ) public { - decimals = uint8(bound(decimals, 1, 18)); validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(user != address(0)); - vm.assume(user.code.length == 0); - vm.assume(currency > 0); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - address lPool_ = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); - centrifugeChain.updateMember(poolId, trancheId, user, validUntil); - assertTrue(trancheToken.checkTransferRestriction(address(0), user, 0)); - } + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); + vm.expectRevert(bytes("PoolManager/not-the-gateway")); + poolManager.updateMember(poolId, trancheId, randomUser, validUntil); - function testUpdatingEscrowMemberFails( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint64 validUntil - ) public { - decimals = uint8(bound(decimals, 1, 18)); - validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(currency > 0); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - address lPool_ = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); + vm.expectRevert(bytes("PoolManager/unknown-token")); + centrifugeChain.updateMember(100, _stringToBytes16("100"), randomUser, validUntil); // use random poolId & trancheId - assertTrue(trancheToken.checkTransferRestriction(address(0), address(escrow), 0)); + centrifugeChain.updateMember(poolId, trancheId, randomUser, validUntil); + assertTrue(trancheToken.checkTransferRestriction(address(0), randomUser, 0)); vm.expectRevert(bytes("PoolManager/escrow-member-cannot-be-updated")); centrifugeChain.updateMember(poolId, trancheId, address(escrow), validUntil); - - assertTrue(trancheToken.checkTransferRestriction(address(0), address(escrow), 0)); } - function testFreezingMemberFails( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint64 validUntil - ) public { - decimals = uint8(bound(decimals, 1, 18)); - validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(currency > 0); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - address lPool_ = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); - - address user = makeAddr("user"); - centrifugeChain.updateMember(poolId, trancheId, user, type(uint64).max); - assertTrue(trancheToken.checkTransferRestriction(address(escrow), user, 0)); - - vm.expectRevert(bytes("PoolManager/escrow-cannot-be-frozen")); - centrifugeChain.freeze(poolId, trancheId, address(escrow)); - - assertTrue(trancheToken.checkTransferRestriction(address(escrow), user, 0)); - } - - function testFreezingAndUnfreezingWorks( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - address user, - address secondUser, - uint64 validUntil + function testFreezeAndUnfreeze( ) public { - decimals = uint8(bound(decimals, 1, 18)); - validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(user != address(0)); - vm.assume(currency > 0); - vm.assume(user.code.length == 0); - vm.assume(secondUser.code.length == 0); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - address lPool_ = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); TrancheTokenLike trancheToken = TrancheTokenLike(address(LiquidityPool(lPool_).share())); + uint64 validUntil = uint64(block.timestamp + 7 days); + address secondUser = makeAddr("secondUser"); - centrifugeChain.updateMember(poolId, trancheId, user, validUntil); + centrifugeChain.updateMember(poolId, trancheId, randomUser, validUntil); centrifugeChain.updateMember(poolId, trancheId, secondUser, validUntil); - assertTrue(trancheToken.checkTransferRestriction(user, secondUser, 0)); + assertTrue(trancheToken.checkTransferRestriction(randomUser, secondUser, 0)); - centrifugeChain.freeze(poolId, trancheId, user); - assertFalse(trancheToken.checkTransferRestriction(user, secondUser, 0)); + centrifugeChain.freeze(poolId, trancheId, randomUser); + assertFalse(trancheToken.checkTransferRestriction(randomUser, secondUser, 0)); - centrifugeChain.unfreeze(poolId, trancheId, user); - assertTrue(trancheToken.checkTransferRestriction(user, secondUser, 0)); - } + centrifugeChain.unfreeze(poolId, trancheId, randomUser); + assertTrue(trancheToken.checkTransferRestriction(randomUser, secondUser, 0)); - function testUpdatingMemberAsNonRouterFails( - uint64 poolId, - uint128 currency, - bytes16 trancheId, - address user, - uint64 validUntil - ) public { - validUntil = uint64(bound(validUntil, block.timestamp, type(uint64).max)); - vm.assume(user != address(0)); - vm.assume(currency > 0); - - vm.expectRevert(bytes("PoolManager/not-the-gateway")); - poolManager.updateMember(poolId, trancheId, user, validUntil); - } - - function testUpdatingMemberForNonExistentTrancheFails( - uint64 poolId, - bytes16 trancheId, - address user, - uint64 validUntil - ) public { - vm.assume(validUntil > block.timestamp); - vm.assume(user.code.length == 0); - centrifugeChain.addPool(poolId); - - vm.expectRevert(bytes("PoolManager/unknown-token")); - centrifugeChain.updateMember(poolId, trancheId, user, validUntil); + vm.expectRevert(bytes("PoolManager/escrow-cannot-be-frozen")); + centrifugeChain.freeze(poolId, trancheId, address(escrow)); } - function testUpdatingTokenMetadataWorks( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, + function testUpdateTokenMetadata( string memory updatedTokenName, string memory updatedTokenSymbol ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - - poolManager.deployTranche(poolId, trancheId); + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); - centrifugeChain.updateTrancheTokenMetadata(poolId, trancheId, updatedTokenName, updatedTokenSymbol); - } - - function testUpdatingTokenMetadataAsNonRouterFails( - uint64 poolId, - uint8 decimals, - uint128 currency, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - string memory updatedTokenName, - string memory updatedTokenSymbol - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); - poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + vm.expectRevert(bytes("PoolManager/unknown-token")); + centrifugeChain.updateTrancheTokenMetadata(100, _stringToBytes16("100"), updatedTokenName, updatedTokenSymbol); vm.expectRevert(bytes("PoolManager/not-the-gateway")); poolManager.updateTrancheTokenMetadata(poolId, trancheId, updatedTokenName, updatedTokenSymbol); - } - - function testUpdatingTokenMetadataForNonExistentTrancheFails( - uint64 poolId, - bytes16 trancheId, - string memory updatedTokenName, - string memory updatedTokenSymbol - ) public { - centrifugeChain.addPool(poolId); - vm.expectRevert(bytes("PoolManager/unknown-token")); centrifugeChain.updateTrancheTokenMetadata(poolId, trancheId, updatedTokenName, updatedTokenSymbol); } - function testAddPoolWorks(uint64 poolId) public { - centrifugeChain.addPool(poolId); - (uint256 createdAt) = poolManager.pools(poolId); - assertEq(createdAt, block.timestamp); - } + function testAllowInvestmentCurrency() public { + uint128 currency = defaultCurrencyId; + uint64 poolId = 1; - function testAllowInvestmentCurrencyWorks(uint128 currency, uint64 poolId) public { - vm.assume(currency > 0); - ERC20 token = _newErc20("X's Dollar", "USDX", 17); - centrifugeChain.addCurrency(currency, address(token)); + centrifugeChain.addCurrency(currency, address(erc20)); centrifugeChain.addPool(poolId); centrifugeChain.allowInvestmentCurrency(poolId, currency); - assertTrue(poolManager.isAllowedAsInvestmentCurrency(poolId, address(token))); + assertTrue(poolManager.isAllowedAsInvestmentCurrency(poolId, address(erc20))); centrifugeChain.disallowInvestmentCurrency(poolId, currency); - assertEq(poolManager.isAllowedAsInvestmentCurrency(poolId, address(token)), false); - } + assertEq(poolManager.isAllowedAsInvestmentCurrency(poolId, address(erc20)), false); - function testAllowInvestmentCurrencyWithUnknownCurrencyFails(uint128 currency, uint64 poolId) public { - centrifugeChain.addPool(poolId); - vm.expectRevert(bytes("PoolManager/unknown-currency")); - centrifugeChain.allowInvestmentCurrency(poolId, currency); + uint128 randomCurrency = 100; vm.expectRevert(bytes("PoolManager/unknown-currency")); - centrifugeChain.disallowInvestmentCurrency(poolId, currency); - } - - function testAddingPoolMultipleTimesFails(uint64 poolId) public { - centrifugeChain.addPool(poolId); - - vm.expectRevert(bytes("PoolManager/pool-already-added")); - centrifugeChain.addPool(poolId); - } - - function testAddingPoolAsNonRouterFails(uint64 poolId) public { - vm.expectRevert(bytes("PoolManager/not-the-gateway")); - poolManager.addPool(poolId); - } + centrifugeChain.allowInvestmentCurrency(poolId, randomCurrency); - function testAddingSingleTrancheWorks( - uint64 poolId, - bytes16 trancheId, - string memory tokenName, - string memory tokenSymbol, - uint8 decimals - ) public { - decimals = uint8(bound(decimals, 1, 18)); - - centrifugeChain.addPool(poolId); - - vm.expectRevert(bytes("PoolManager/too-many-tranche-token-decimals")); - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, 19); - - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); - poolManager.deployTranche(poolId, trancheId); - - TrancheToken trancheToken = TrancheToken(poolManager.getTrancheToken(poolId, trancheId)); - - assertEq( - _bytes128ToString(_stringToBytes128(tokenName)), _bytes128ToString(_stringToBytes128(trancheToken.name())) - ); - assertEq( - _bytes32ToString(_stringToBytes32(tokenSymbol)), _bytes32ToString(_stringToBytes32(trancheToken.symbol())) - ); - assertEq(decimals, trancheToken.decimals()); + vm.expectRevert(bytes("PoolManager/unknown-currency")); + centrifugeChain.disallowInvestmentCurrency(poolId, randomCurrency); } - function testAddingTrancheMultipleTimesFails( + function testUpdateTokenPriceWorks( uint64 poolId, uint8 decimals, + uint128 currencyId, string memory tokenName, string memory tokenSymbol, - bytes16 trancheId - ) public { - decimals = uint8(bound(decimals, 1, 18)); - - centrifugeChain.addPool(poolId); - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); - - vm.expectRevert(bytes("PoolManager/tranche-already-exists")); - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); - } - - function testAddingMultipleTranchesWorks( - uint64 poolId, - bytes16[4] calldata trancheIds, - string memory tokenName, - string memory tokenSymbol, - uint8 decimals - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(!hasDuplicates(trancheIds)); - centrifugeChain.addPool(poolId); - - for (uint256 i = 0; i < trancheIds.length; i++) { - centrifugeChain.addTranche(poolId, trancheIds[i], tokenName, tokenSymbol, decimals); - poolManager.deployTranche(poolId, trancheIds[i]); - TrancheToken trancheToken = TrancheToken(poolManager.getTrancheToken(poolId, trancheIds[i])); - assertEq(decimals, trancheToken.decimals()); - } - } - - function testAddingTranchesAsNonRouterFails( - uint64 poolId, bytes16 trancheId, - string memory tokenName, - string memory tokenSymbol, - uint8 decimals + uint128 price ) public { decimals = uint8(bound(decimals, 1, 18)); - + vm.assume(poolId > 0); + vm.assume(trancheId > 0); + vm.assume(currencyId > 0); centrifugeChain.addPool(poolId); - vm.expectRevert(bytes("PoolManager/not-the-gateway")); - poolManager.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); - } - - function testAddingTranchesForNonExistentPoolFails( - uint64 poolId, - bytes16 trancheId, - string memory tokenName, - string memory tokenSymbol, - uint8 decimals - ) public { - decimals = uint8(bound(decimals, 1, 18)); - - vm.expectRevert(bytes("PoolManager/invalid-pool")); - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); - } - - function testDeployLiquidityPool( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - - address trancheToken_ = poolManager.deployTranche(poolId, trancheId); - address lPoolAddress = poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - address lPool_ = poolManager.getLiquidityPool(poolId, trancheId, address(erc20)); // make sure the pool was stored in LP - - // make sure the pool was added to the tranche struct - assertEq(lPoolAddress, lPool_); - - // check LiquidityPool state - LiquidityPool lPool = LiquidityPool(lPool_); - TrancheToken trancheToken = TrancheToken(trancheToken_); - assertEq(address(lPool.investmentManager()), address(investmentManager)); - assertEq(lPool.asset(), address(erc20)); - assertEq(lPool.poolId(), poolId); - assertEq(lPool.trancheId(), trancheId); - assertEq(address(lPool.share()), trancheToken_); - - assertTrue(lPool.wards(address(investmentManager)) == 1); - assertTrue(lPool.wards(address(this)) == 0); - assertTrue(investmentManager.wards(lPoolAddress) == 1); - - assertEq(trancheToken.name(), _bytes128ToString(_stringToBytes128(tokenName))); - assertEq(trancheToken.symbol(), _bytes32ToString(_stringToBytes32(tokenSymbol))); - assertEq(trancheToken.decimals(), decimals); - assertTrue( - RestrictionManagerLike(address(trancheToken.restrictionManager())).hasMember( - address(investmentManager.escrow()) - ) - ); - - assertTrue(trancheToken.wards(address(poolManager)) == 1); - assertTrue(trancheToken.wards(lPool_) == 1); - assertTrue(trancheToken.wards(address(this)) == 0); - - assertTrue(trancheToken.isTrustedForwarder(lPool_)); // Lpool is not trusted forwarder on token - } - - function testDeployingLiquidityPoolNonExistingTrancheFails( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - bytes16 wrongTrancheId, - uint128 currency - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - vm.assume(trancheId != wrongTrancheId); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - vm.expectRevert(bytes("PoolManager/tranche-does-not-exist")); - poolManager.deployLiquidityPool(poolId, wrongTrancheId, address(erc20)); - } - - function testDeployingLiquidityPoolNonExistingPoolFails( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint64 wrongPoolId, - uint128 currency - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - vm.assume(poolId != wrongPoolId); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); vm.expectRevert(bytes("PoolManager/tranche-does-not-exist")); - poolManager.deployLiquidityPool(wrongPoolId, trancheId, address(erc20)); - } + centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, price, uint64(block.timestamp)); - function testDeployingLiquidityInvestmentCurrencyNotSupportedFails( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); + centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); + centrifugeChain.addCurrency(currencyId, address(erc20)); + centrifugeChain.allowInvestmentCurrency(poolId, currencyId); - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche poolManager.deployTranche(poolId, trancheId); - centrifugeChain.addCurrency(currency, address(erc20)); - - vm.expectRevert(bytes("PoolManager/currency-not-supported")); - poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - } + // Allows us to go back in time later + vm.warp(block.timestamp + 1 days); - function testDeployLiquidityPoolTwiceFails( - uint64 poolId, - uint8 decimals, - string memory tokenName, - string memory tokenSymbol, - bytes16 trancheId, - uint128 currency - ) public { - decimals = uint8(bound(decimals, 1, 18)); - vm.assume(currency > 0); - - centrifugeChain.addPool(poolId); // add pool - centrifugeChain.addTranche(poolId, trancheId, tokenName, tokenSymbol, decimals); // add tranche + vm.expectRevert(bytes("PoolManager/not-the-gateway")); + poolManager.updateTrancheTokenPrice(poolId, trancheId, currencyId, price, uint64(block.timestamp)); - centrifugeChain.addCurrency(currency, address(erc20)); - centrifugeChain.allowInvestmentCurrency(poolId, currency); - poolManager.deployTranche(poolId, trancheId); + centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, price, uint64(block.timestamp)); + (uint256 latestPrice, uint64 priceComputedAt) = poolManager.getTrancheTokenPrice(poolId, trancheId, address(erc20)); + assertEq(latestPrice, price); + assertEq(priceComputedAt, block.timestamp); - poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); - vm.expectRevert(bytes("PoolManager/liquidity-pool-already-deployed")); - poolManager.deployLiquidityPool(poolId, trancheId, address(erc20)); + vm.expectRevert(bytes("PoolManager/cannot-set-older-price")); + centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, price, uint64(block.timestamp - 1)); } // helpers diff --git a/test/TestSetup.t.sol b/test/TestSetup.t.sol index ec0c7d4a..69f460af 100644 --- a/test/TestSetup.t.sol +++ b/test/TestSetup.t.sol @@ -32,12 +32,13 @@ contract TestSetup is Deployer, Test { address self = address(this); address investor = makeAddr("investor"); + address randomUser = makeAddr("randomUser"); uint128 constant MAX_UINT128 = type(uint128).max; // default values uint128 defaultCurrencyId = 1; - uint128 defaultPrice = 1; + uint128 defaultPrice = 1 * 10**18; function setUp() public virtual { vm.chainId(1); @@ -110,7 +111,36 @@ contract TestSetup is Deployer, Test { } function deploySimplePool() public returns (address) { - return deployLiquidityPool(1, 6, "name", "symbol", _stringToBytes16("1"), defaultCurrencyId, address(erc20)); + return deployLiquidityPool(5, 6, "name", "symbol", _stringToBytes16("1"), defaultCurrencyId, address(erc20)); + } + + function deposit(address _lPool, address _investor, uint256 amount) public { + deposit(_lPool, _investor, amount, true); + } + + function deposit(address _lPool, address _investor, uint256 amount, bool claimDeposit) public { + LiquidityPool lPool = LiquidityPool(_lPool); + erc20.mint(_investor, amount); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), _investor, type(uint64).max); // add user as member + vm.startPrank(_investor); + erc20.approve(_lPool, amount); // add allowance + lPool.requestDeposit(amount, _investor); + // trigger executed collectInvest + uint128 currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(_investor)), + currencyId, + uint128(amount), + uint128(amount), + 0 + ); + + if (claimDeposit) { + lPool.deposit(amount, _investor); // claim the trancheTokens + } + vm.stopPrank(); } // Helpers @@ -147,6 +177,18 @@ contract TestSetup is Deployer, Test { } } + function _bytes16ToString(bytes16 _bytes16) public pure returns (string memory) { + uint8 i = 0; + while(i < 16 && _bytes16[i] != 0) { + i++; + } + bytes memory bytesArray = new bytes(i); + for (i = 0; i < 16 && _bytes16[i] != 0; i++) { + bytesArray[i] = _bytes16[i]; + } + return string(bytesArray); + } + function _bytes32ToString(bytes32 _bytes32) internal pure returns (string memory) { uint8 i = 0; while (i < 32 && _bytes32[i] != 0) { @@ -191,4 +233,21 @@ contract TestSetup is Deployer, Test { return string(bytesArray); } + + function random(uint256 maxValue, uint256 nonce) internal view returns (uint256) { + if (maxValue == 1) { + return maxValue; + } + uint256 randomnumber = uint256(keccak256(abi.encodePacked(block.timestamp, self, nonce))) % (maxValue - 1); + return randomnumber + 1; + } + + // assumptions + function amountAssumption(uint256 amount) public pure returns (bool) { + return (amount > 1 && amount < MAX_UINT128); + } + + function addressAssumption(address user) public view returns (bool) { + return (user != address(0) && user != address(erc20) && user.code.length == 0); + } } diff --git a/test/gateway/Messages.t.sol b/test/gateway/Messages.t.sol index 309d3aa2..7b7e5d8e 100644 --- a/test/gateway/Messages.t.sol +++ b/test/gateway/Messages.t.sol @@ -118,29 +118,46 @@ contract MessagesTest is Test { bytes16 trancheId = bytes16(hex"811acd5b3f17c06841c7e41e9e04cb1b"); uint128 currencyId = 2; uint128 price = 1_000_000_000_000_000_000_000_000_000; + uint64 computedAt = uint64(block.timestamp); bytes memory expectedHex = - hex"050000000000000001811acd5b3f17c06841c7e41e9e04cb1b0000000000000000000000000000000200000000033b2e3c9fd0803ce8000000"; + hex"050000000000000001811acd5b3f17c06841c7e41e9e04cb1b0000000000000000000000000000000200000000033b2e3c9fd0803ce80000000000000000000001"; - assertEq(Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price), expectedHex); + assertEq(Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price, computedAt), expectedHex); - (uint64 decodedPoolId, bytes16 decodedTrancheId, uint128 decodedCurrencyId, uint128 decodedPrice) = - Messages.parseUpdateTrancheTokenPrice(expectedHex); + ( + uint64 decodedPoolId, + bytes16 decodedTrancheId, + uint128 decodedCurrencyId, + uint128 decodedPrice, + uint64 decodedComputedAt + ) = Messages.parseUpdateTrancheTokenPrice(expectedHex); assertEq(uint256(decodedPoolId), poolId); assertEq(decodedTrancheId, trancheId); assertEq(decodedCurrencyId, currencyId); assertEq(decodedPrice, price); + assertEq(decodedComputedAt, computedAt); } - function testUpdateTrancheTokenPriceEquivalence(uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) - public - { - bytes memory _message = Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price); - (uint64 decodedPoolId, bytes16 decodedTrancheId, uint128 decodedCurrencyId, uint128 decodedPrice) = - Messages.parseUpdateTrancheTokenPrice(_message); + function testUpdateTrancheTokenPriceEquivalence( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId, + uint128 price, + uint64 computedAt + ) public { + bytes memory _message = Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price, computedAt); + ( + uint64 decodedPoolId, + bytes16 decodedTrancheId, + uint128 decodedCurrencyId, + uint128 decodedPrice, + uint64 decodedComputedAt + ) = Messages.parseUpdateTrancheTokenPrice(_message); assertEq(uint256(decodedPoolId), uint256(poolId)); assertEq(decodedTrancheId, trancheId); assertEq(decodedCurrencyId, currencyId); assertEq(uint256(decodedPrice), uint256(price)); + assertEq(decodedComputedAt, computedAt); } // Note: UpdateMember encodes differently in Solidity compared to the Rust counterpart because `user` is a 20-byte diff --git a/test/integration/AssetShareConversion.t.sol b/test/integration/AssetShareConversion.t.sol new file mode 100644 index 00000000..a606449b --- /dev/null +++ b/test/integration/AssetShareConversion.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import "./../TestSetup.t.sol"; + +contract AssetShareConversionTest is TestSetup { + function testAssetShareConversion(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000, uint64(block.timestamp)); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a price of 1.0 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 trancheTokenPayout = 100000000000000000000; // 100 * 10**18 + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, uint128(investmentAmount), trancheTokenPayout, 0 + ); + lPool.mint(trancheTokenPayout, self); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000, uint64(block.timestamp) + ); + + // assert share/asset conversion + assertEq(lPool.totalSupply(), 100000000000000000000); + assertEq(lPool.totalAssets(), 100000000); + assertEq(lPool.convertToShares(100000000), 100000000000000000000); // tranche tokens have 12 more decimals than + // assets + assertEq(lPool.convertToAssets(lPool.convertToShares(100000000000000000000)), 100000000000000000000); + + // assert share/asset conversion after price update + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1200000000000000000, uint64(block.timestamp) + ); + + assertEq(lPool.totalAssets(), 120000000); + assertEq(lPool.convertToShares(120000000), 100000000000000000000); // tranche tokens have 12 more decimals than + // assets + assertEq(lPool.convertToAssets(lPool.convertToShares(120000000000000000000)), 120000000000000000000); + } + + function testAssetShareConversionWithInverseDecimals(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like DAI + uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice(poolId, trancheId, currencyId, 1000000, uint64(block.timestamp)); + + // invest + uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a price of 1.0 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 trancheTokenPayout = 100000000; // 100 * 10**6 + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, uint128(investmentAmount), trancheTokenPayout, 0 + ); + lPool.mint(trancheTokenPayout, self); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000, uint64(block.timestamp) + ); + + // assert share/asset conversion + assertEq(lPool.totalSupply(), 100000000); + assertEq(lPool.totalAssets(), 100000000000000000000); + assertEq(lPool.convertToShares(100000000000000000000), 100000000); // tranche tokens have 12 less decimals than + // assets + assertEq(lPool.convertToAssets(lPool.convertToShares(100000000000000000000)), 100000000000000000000); + + // assert share/asset conversion after price update + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1200000000000000000, uint64(block.timestamp) + ); + + assertEq(lPool.totalAssets(), 120000000000000000000); + assertEq(lPool.convertToShares(120000000000000000000), 100000000); // tranche tokens have 12 less decimals than + // assets + assertEq(lPool.convertToAssets(lPool.convertToShares(120000000000000000000)), 120000000000000000000); + } +} diff --git a/test/integration/Burn.t.sol b/test/integration/Burn.t.sol new file mode 100644 index 00000000..c32917a1 --- /dev/null +++ b/test/integration/Burn.t.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import "./../TestSetup.t.sol"; + +contract BurnTest is TestSetup { + function testBurn(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); + root.relyContract(address(trancheToken), self); // give self auth permissions + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); // add investor as + // member + + trancheToken.mint(investor, amount); + root.denyContract(address(trancheToken), self); // remove auth permissions from self + + vm.expectRevert(bytes("Auth/not-authorized")); + trancheToken.burn(investor, amount); + + root.relyContract(address(trancheToken), self); // give self auth permissions + vm.expectRevert(bytes("ERC20/insufficient-allowance")); + trancheToken.burn(investor, amount); + + // success + vm.prank(investor); + lPool.approve(self, amount); // approve to burn tokens + trancheToken.burn(investor, amount); + + assertEq(lPool.balanceOf(investor), 0); + assertEq(lPool.balanceOf(investor), trancheToken.balanceOf(investor)); + } +} diff --git a/test/integration/Deposit.t.sol b/test/integration/Deposit.t.sol new file mode 100644 index 00000000..84db2ace --- /dev/null +++ b/test/integration/Deposit.t.sol @@ -0,0 +1,795 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import "./../TestSetup.t.sol"; + +contract DepositTest is TestSetup { + function testDepositMint(uint256 amount) public { + // If lower than 4 or odd, rounding down can lead to not receiving any tokens + amount = uint128(bound(amount, 4, MAX_UINT128)); + vm.assume(amount % 2 == 0); + + uint128 price = 2 * 10 ** 18; + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price, uint64(block.timestamp) + ); + + erc20.mint(self, amount); + + // will fail - user not member: can not receive trancheToken + vm.expectRevert(bytes("InvestmentManager/sender-is-restricted")); + lPool.requestDeposit(amount, self); + + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member + + // will fail - user did not give currency allowance to liquidity pool + vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); + lPool.requestDeposit(amount, self); + + // success + erc20.approve(lPool_, amount); + lPool.requestDeposit(amount, self); + + // fail: no currency left + vm.expectRevert(bytes("LiquidityPool/insufficient-balance")); + lPool.requestDeposit(amount, self); + + // ensure funds are locked in escrow + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(self), 0); + assertEq(lPool.pendingDepositRequest(self), amount); + + // trigger executed collectInvest + uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId + uint128 trancheTokensPayout = uint128((amount * 10 ** 18) / price); // trancheTokenPrice = 2$ + assertApproxEqAbs(trancheTokensPayout, amount / 2, 2); + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(self)), + _currencyId, + uint128(amount), + trancheTokensPayout, + 0 + ); + + // assert deposit & mint values adjusted + assertEq(lPool.maxMint(self), trancheTokensPayout); + assertApproxEqAbs(lPool.maxDeposit(self), amount, 1); + assertEq(lPool.pendingDepositRequest(self), 0); + // assert tranche tokens minted + assertEq(lPool.balanceOf(address(escrow)), trancheTokensPayout); + + // deposit 50% of the amount + lPool.deposit(amount / 2, self); // mint half the amount + + // Allow 2 difference because of rounding + assertApproxEqAbs(lPool.balanceOf(self), trancheTokensPayout / 2, 2); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), trancheTokensPayout - trancheTokensPayout / 2, 2); + assertApproxEqAbs(lPool.maxMint(self), trancheTokensPayout - trancheTokensPayout / 2, 2); + assertApproxEqAbs(lPool.maxDeposit(self), amount - amount / 2, 2); + + // mint the rest + lPool.mint(lPool.maxMint(self), self); + assertEq(lPool.balanceOf(self), trancheTokensPayout - lPool.maxMint(self)); + assertTrue(lPool.balanceOf(address(escrow)) <= 1); + assertTrue(lPool.maxMint(self) <= 1); + + // remainder is rounding difference + assertTrue(lPool.maxDeposit(self) <= amount * 0.01e18); + } + + function testPartialExecutions(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + + // first trigger executed collectInvest of the first 50% at a price of 1.4 + uint128 currencyPayout = 50000000; // 50 * 10**6 + uint128 firstTrancheTokenPayout = 35714285714285714285; // 50 * 10**18 / 1.4, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout, + currencyPayout + ); + + (, uint256 depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1400000000000000000); + + // second trigger executed collectInvest of the second 50% at a price of 1.2 + uint128 secondTrancheTokenPayout = 41666666666666666666; // 50 * 10**18 / 1.2, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout, 0 + ); + + (, depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1292307679384615384); + + // assert deposit & mint values adjusted + assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout * 2, 2); + assertEq(lPool.maxMint(self), firstTrancheTokenPayout + secondTrancheTokenPayout); + + // collect the tranche tokens + lPool.mint(firstTrancheTokenPayout + secondTrancheTokenPayout, self); + assertEq(lPool.balanceOf(self), firstTrancheTokenPayout + secondTrancheTokenPayout); + } + + function testDepositFairRounding(uint256 totalAmount, uint256 tokenAmount) public { + totalAmount = bound(totalAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); + tokenAmount = bound(tokenAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); + + //Deploy a pool + LiquidityPool lPool = LiquidityPool(deploySimplePool()); + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); + + root.relyContract(address(trancheToken), self); + trancheToken.mint(address(escrow), type(uint128).max); // mint buffer to the escrow. Mock funds from other users + + // fund user & request deposit + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, uint64(block.timestamp)); + erc20.mint(self, totalAmount); + erc20.approve(address(lPool), totalAmount); + lPool.requestDeposit(totalAmount, self); + + // Ensure funds were locked in escrow + assertEq(erc20.balanceOf(address(escrow)), totalAmount); + assertEq(erc20.balanceOf(self), 0); + + // Gateway returns randomly generated values for amount of tranche tokens and currency + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(self)), + defaultCurrencyId, + uint128(totalAmount), + uint128(tokenAmount), + 0 + ); + + // user claims multiple partial deposits + vm.assume(lPool.maxDeposit(self) > 0); + assertEq(erc20.balanceOf(self), 0); + while (lPool.maxDeposit(self) > 0) { + uint256 randomDeposit = random(lPool.maxDeposit(self), 1); + + try lPool.deposit(randomDeposit, self) { + if (lPool.maxDeposit(self) == 0 && lPool.maxMint(self) > 0) { + // If you cannot deposit anymore because the 1 wei remaining is rounded down, + // you should mint the remainder instead. + lPool.mint(lPool.maxMint(self), self); + break; + } + } catch { + // If you cannot deposit anymore because the 1 wei remaining is rounded down, + // you should mint the remainder instead. + lPool.mint(lPool.maxMint(self), self); + break; + } + } + + assertEq(lPool.maxDeposit(self), 0); + assertApproxEqAbs(lPool.balanceOf(self), tokenAmount, 1); + } + + function testMintFairRounding(uint256 totalAmount, uint256 tokenAmount) public { + totalAmount = bound(totalAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); + tokenAmount = bound(tokenAmount, 1 * 10 ** 6, type(uint128).max / 10 ** 12); + + //Deploy a pool + LiquidityPool lPool = LiquidityPool(deploySimplePool()); + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); + + root.relyContract(address(trancheToken), self); + trancheToken.mint(address(escrow), type(uint128).max); // mint buffer to the escrow. Mock funds from other users + + // fund user & request deposit + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, uint64(block.timestamp)); + erc20.mint(self, totalAmount); + erc20.approve(address(lPool), totalAmount); + lPool.requestDeposit(totalAmount, self); + + // Ensure funds were locked in escrow + assertEq(erc20.balanceOf(address(escrow)), totalAmount); + assertEq(erc20.balanceOf(self), 0); + + // Gateway returns randomly generated values for amount of tranche tokens and currency + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(self)), + defaultCurrencyId, + uint128(totalAmount), + uint128(tokenAmount), + 0 + ); + + // user claims multiple partial mints + uint256 i = 0; + while (lPool.maxMint(self) > 0) { + uint256 randomMint = random(lPool.maxMint(self), i); + try lPool.mint(randomMint, self) { + i++; + } catch { + break; + } + } + + assertEq(lPool.maxMint(self), 0); + assertLe(lPool.balanceOf(self), tokenAmount); + } + + function testDepositMintToReceiver(uint256 amount) public { + // If lower than 4 or odd, rounding down can lead to not receiving any tokens + amount = uint128(bound(amount, 4, MAX_UINT128)); + vm.assume(amount % 2 == 0); + + uint128 price = 2 * 10 ** 18; + address lPool_ = deploySimplePool(); + address receiver = makeAddr("receiver"); + LiquidityPool lPool = LiquidityPool(lPool_); + + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price, uint64(block.timestamp) + ); + + erc20.mint(self, amount); + + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member + erc20.approve(lPool_, amount); // add allowance + lPool.requestDeposit(amount, self); + + // trigger executed collectInvest + uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId + uint128 trancheTokensPayout = uint128(amount * 10 ** 18 / price); // trancheTokenPrice = 2$ + assertApproxEqAbs(trancheTokensPayout, amount / 2, 2); + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(self)), + _currencyId, + uint128(amount), + trancheTokensPayout, + 0 + ); + + // assert deposit & mint values adjusted + assertEq(lPool.maxMint(self), trancheTokensPayout); // max deposit + assertEq(lPool.maxDeposit(self), amount); // max deposit + // assert tranche tokens minted + assertEq(lPool.balanceOf(address(escrow)), trancheTokensPayout); + + // deposit 1/2 funds to receiver + vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); + lPool.deposit(amount / 2, receiver); // mint half the amount + + vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); + lPool.mint(amount / 2, receiver); // mint half the amount + + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), receiver, type(uint64).max); // add receiver + // member + + // success + lPool.deposit(amount / 2, receiver); // mint half the amount + lPool.mint(lPool.maxMint(self), receiver); // mint half the amount + + assertApproxEqAbs(lPool.balanceOf(receiver), trancheTokensPayout, 1); + assertApproxEqAbs(lPool.balanceOf(receiver), trancheTokensPayout, 1); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), 0, 1); + assertApproxEqAbs(erc20.balanceOf(address(escrow)), amount, 1); + } + + function testDepositWithPermitFR(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + // Use a wallet with a known private key so we can sign the permit message + address investor = vm.addr(0xABCD); + vm.prank(vm.addr(0xABCD)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + erc20.mint(investor, amount); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); + + // Sign permit for depositing investment currency + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + 0xABCD, + keccak256( + abi.encodePacked( + "\x19\x01", + erc20.DOMAIN_SEPARATOR(), + keccak256(abi.encode(erc20.PERMIT_TYPEHASH(), investor, lPool_, amount, 0, block.timestamp)) + ) + ) + ); + + vm.startPrank(randomUser); // random fr permit + erc20.permit(investor, lPool_, amount, block.timestamp, v, r, s); + // frontrunnign not possible + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), randomUser, type(uint64).max); + vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); + lPool.requestDepositWithPermit((amount), block.timestamp, v, r, s); + vm.stopPrank(); + + // investor still able to requestDepositWithPermit + vm.prank(vm.addr(0xABCD)); + lPool.requestDepositWithPermit(amount, block.timestamp, v, r, s); + + // ensure funds are locked in escrow + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(investor), 0); + } + + function testDepositWithPermit(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + // Use a wallet with a known private key so we can sign the permit message + address investor = vm.addr(0xABCD); + address randomUser = makeAddr("randomUser"); + vm.prank(investor); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + erc20.mint(investor, amount); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), address(this), type(uint64).max); + + // Sign permit for depositing investment currency + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + 0xABCD, + keccak256( + abi.encodePacked( + "\x19\x01", + erc20.DOMAIN_SEPARATOR(), + keccak256(abi.encode(erc20.PERMIT_TYPEHASH(), investor, lPool_, amount, 0, block.timestamp)) + ) + ) + ); + + // premit functions can only be executed by the owner + vm.expectRevert(bytes("SafeTransferLib/safe-transfer-from-failed")); + lPool.requestDepositWithPermit(amount, block.timestamp, v, r, s); + vm.prank(vm.addr(0xABCD)); + lPool.requestDepositWithPermit(amount, block.timestamp, v, r, s); + + // To avoid stack too deep errors + delete v; + delete r; + delete s; + + // ensure funds are locked in escrow + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(investor), 0); + + // collect 50% of the tranche tokens + centrifugeChain.isExecutedCollectInvest( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(investor)), + poolManager.currencyAddressToId(address(erc20)), + uint128(amount), + uint128(amount), + 0 + ); + + uint256 maxMint = lPool.maxMint(investor); + vm.prank(vm.addr(0xABCD)); + lPool.mint(maxMint, investor); + + TrancheToken trancheToken = TrancheToken(address(lPool.share())); + assertEq(trancheToken.balanceOf(address(investor)), maxMint); + } + + function testDepositAndRedeemPrecision(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest of the first 50% at a price of 1.2 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 50000000; // 50 * 10**6 + uint128 firstTrancheTokenPayout = 41666666666666666666; // 50 * 10**18 / 1.2, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout, + currencyPayout / 2 + ); + + // assert deposit & mint values adjusted + assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout, 1); + assertEq(lPool.maxMint(self), firstTrancheTokenPayout); + + // deposit price should be ~1.2*10**18 + (, uint256 depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1200000000000000000); + + // trigger executed collectInvest of the second 50% at a price of 1.4 + currencyPayout = 50000000; // 50 * 10**6 + uint128 secondTrancheTokenPayout = 35714285714285714285; // 50 * 10**18 / 1.4, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout, 0 + ); + + // collect the tranche tokens + lPool.mint(firstTrancheTokenPayout + secondTrancheTokenPayout, self); + assertEq(lPool.balanceOf(self), firstTrancheTokenPayout + secondTrancheTokenPayout); + + // redeem + lPool.requestRedeem(firstTrancheTokenPayout + secondTrancheTokenPayout, address(this), address(this)); + + // trigger executed collectRedeem at a price of 1.5 + // 50% invested at 1.2 and 50% invested at 1.4 leads to ~77 tranche tokens + // when redeeming at a price of 1.5, this leads to ~115.5 currency + currencyPayout = 115500000; // 115.5*10**6 + + // mint interest into escrow + currency.mint(address(escrow), currencyPayout - investmentAmount); + + centrifugeChain.isExecutedCollectRedeem( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout + secondTrancheTokenPayout, + 0 + ); + + // redeem price should now be ~1.5*10**18. + (,,, uint256 redeemPrice,,,) = investmentManager.investments(address(lPool), self); + assertEq(redeemPrice, 1492615384615384615); + + // collect the currency + lPool.withdraw(currencyPayout, self, self); + assertEq(currency.balanceOf(self), currencyPayout); + } + + function testDepositAndRedeemPrecisionWithInverseDecimals(uint64 poolId, bytes16 trancheId, uint128 currencyId) + public + { + vm.assume(currencyId > 0); + + // uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like DAI + // uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like USDC + + ERC20 currency = _newErc20("Currency", "CR", 18); + address lPool_ = deployLiquidityPool(poolId, 6, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000000000000, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest of the first 50% at a price of 1.2 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 50000000000000000000; // 50 * 10**18 + uint128 firstTrancheTokenPayout = 41666666; // 50 * 10**6 / 1.2, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout, + currencyPayout / 2 + ); + + // assert deposit & mint values adjusted + assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout, 10); + assertEq(lPool.maxMint(self), firstTrancheTokenPayout); + + // deposit price should be ~1.2*10**18 + (, uint256 depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1200000019200000307); + + // trigger executed collectInvest of the second 50% at a price of 1.4 + currencyPayout = 50000000000000000000; // 50 * 10**18 + uint128 secondTrancheTokenPayout = 35714285; // 50 * 10**6 / 1.4, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, secondTrancheTokenPayout, 0 + ); + + // collect the tranche tokens + lPool.mint(firstTrancheTokenPayout + secondTrancheTokenPayout, self); + assertEq(lPool.balanceOf(self), firstTrancheTokenPayout + secondTrancheTokenPayout); + + // redeem + lPool.requestRedeem(firstTrancheTokenPayout + secondTrancheTokenPayout, address(this), address(this)); + + // trigger executed collectRedeem at a price of 1.5 + // 50% invested at 1.2 and 50% invested at 1.4 leads to ~77 tranche tokens + // when redeeming at a price of 1.5, this leads to ~115.5 currency + currencyPayout = 115500000000000000000; // 115.5*10**18 + + // mint interest into escrow + currency.mint(address(escrow), currencyPayout - investmentAmount); + + centrifugeChain.isExecutedCollectRedeem( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout + secondTrancheTokenPayout, + 0 + ); + + // redeem price should now be ~1.5*10**18. + (,,, uint256 redeemPrice,,,) = investmentManager.investments(address(lPool), self); + assertEq(redeemPrice, 1492615411252828877); + + // collect the currency + lPool.withdraw(currencyPayout, self, self); + assertEq(currency.balanceOf(self), currencyPayout); + } + + // Test that assumes the swap from usdc (investment currency) to dai (pool currency) has a cost of 1% + function testDepositAndRedeemPrecisionWithSlippage(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + + // price = (100*10**18) / (99 * 10**18) = 101.010101 * 10**18 + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1010101010101010101, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a tranche token price of 1.2 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 99000000; // 99 * 10**6 + + // invested amount in dai is 99 * 10**18 + // executed at price of 1.2, leads to a tranche token payout of + // 99 * 10**18 / 1.2 = 82500000000000000000 + uint128 trancheTokenPayout = 82500000000000000000; + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, trancheTokenPayout, 0 + ); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1200000000000000000, uint64(block.timestamp) + ); + + // assert deposit & mint values adjusted + assertEq(lPool.maxDeposit(self), currencyPayout); + assertEq(lPool.maxMint(self), trancheTokenPayout); + + // lp price is set to the deposit price + (, uint256 depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1200000000000000000); + } + + // Test that assumes the swap from usdc (investment currency) to dai (pool currency) has a cost of 1% + function testDepositAndRedeemPrecisionWithSlippageAndWithInverseDecimal( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId + ) public { + vm.assume(currencyId > 0); + + uint8 INVESTMENT_CURRENCY_DECIMALS = 18; // 18, like DAI + uint8 TRANCHE_TOKEN_DECIMALS = 6; // Like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + + // price = (100*10**18) / (99 * 10**18) = 101.010101 * 10**18 + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1010101010101010101, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000000000000000; // 100 * 10**18 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest at a tranche token price of 1.2 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 99000000000000000000; // 99 * 10**18 + + // invested amount in dai is 99 * 10**18 + // executed at price of 1.2, leads to a tranche token payout of + // 99 * 10**6 / 1.2 = 82500000 + uint128 trancheTokenPayout = 82500000; + centrifugeChain.isExecutedCollectInvest( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, currencyPayout, trancheTokenPayout, 0 + ); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1200000000000000000, uint64(block.timestamp) + ); + + // assert deposit & mint values adjusted + assertEq(lPool.maxDeposit(self), currencyPayout); + assertEq(lPool.maxMint(self), trancheTokenPayout); + + // lp price is set to the deposit price + (, uint256 depositPrice,,,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1200000000000000000); + } + + function testDecreaseDepositPrecision(uint64 poolId, bytes16 trancheId, uint128 currencyId) public { + vm.assume(currencyId > 0); + + uint8 TRANCHE_TOKEN_DECIMALS = 18; // Like DAI + uint8 INVESTMENT_CURRENCY_DECIMALS = 6; // 6, like USDC + + ERC20 currency = _newErc20("Currency", "CR", INVESTMENT_CURRENCY_DECIMALS); + address lPool_ = + deployLiquidityPool(poolId, TRANCHE_TOKEN_DECIMALS, "", "", trancheId, currencyId, address(currency)); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + poolId, trancheId, currencyId, 1000000000000000000, uint64(block.timestamp) + ); + + // invest + uint256 investmentAmount = 100000000; // 100 * 10**6 + centrifugeChain.updateMember(poolId, trancheId, self, type(uint64).max); + currency.approve(lPool_, investmentAmount); + currency.mint(self, investmentAmount); + lPool.requestDeposit(investmentAmount, self); + + // trigger executed collectInvest of the first 50% at a price of 1.2 + uint128 _currencyId = poolManager.currencyAddressToId(address(currency)); // retrieve currencyId + uint128 currencyPayout = 50000000; // 50 * 10**6 + uint128 firstTrancheTokenPayout = 41666666666666666666; // 50 * 10**18 / 1.2, rounded down + centrifugeChain.isExecutedCollectInvest( + poolId, + trancheId, + bytes32(bytes20(self)), + _currencyId, + currencyPayout, + firstTrancheTokenPayout, + uint128(investmentAmount) - currencyPayout + ); + + // assert deposit & mint values adjusted + assertApproxEqAbs(lPool.maxDeposit(self), currencyPayout, 1); + assertEq(lPool.maxMint(self), firstTrancheTokenPayout); + + // decrease the remaining 50% + uint256 decreaseAmount = 50000000; + lPool.decreaseDepositRequest(decreaseAmount); + centrifugeChain.isExecutedDecreaseInvestOrder( + poolId, trancheId, bytes32(bytes20(self)), _currencyId, uint128(decreaseAmount), 0 + ); + + // deposit price should be ~1.2*10**18, redeem price should be 1.0*10**18 + (, uint256 depositPrice,, uint256 redeemPrice,,,) = investmentManager.investments(address(lPool), self); + assertEq(depositPrice, 1200000000000000000); + assertEq(redeemPrice, 1000000000000000000); + assertEq(lPool.maxWithdraw(self), 50000000); + assertEq(lPool.maxRedeem(self), 50000000000000000000); + } + + function testDecreaseDepositRequest(uint256 amount, uint256 decreaseAmount) public { + decreaseAmount = uint128(bound(decreaseAmount, 2, MAX_UINT128 - 1)); + amount = uint128(bound(amount, decreaseAmount + 1, MAX_UINT128)); // amount > decreaseAmount + uint128 price = 2 * 10 ** 18; + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price, uint64(block.timestamp) + ); + + erc20.mint(self, amount); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); // add user as member + erc20.approve(lPool_, amount); // add allowance + lPool.requestDeposit(amount, self); + + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(self), 0); + + // decrease deposit request + lPool.decreaseDepositRequest(decreaseAmount); + centrifugeChain.isExecutedDecreaseInvestOrder( + lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), defaultCurrencyId, uint128(decreaseAmount), 0 + ); + + assertEq(erc20.balanceOf(address(escrow)), amount - decreaseAmount); + assertEq(erc20.balanceOf(address(userEscrow)), decreaseAmount); + assertEq(erc20.balanceOf(self), 0); + assertEq(lPool.maxWithdraw(self), decreaseAmount); + assertEq(lPool.maxRedeem(self), decreaseAmount); + } + + function testCancelDepositOrder(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + uint128 price = 2 * 10 ** 18; + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, price, uint64(block.timestamp) + ); + erc20.mint(self, amount); + erc20.approve(lPool_, amount); // add allowance + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), self, type(uint64).max); + + lPool.requestDeposit(amount, self); + + assertEq(erc20.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(address(self)), 0); + + // check message was send out to centchain + lPool.cancelDepositRequest(); + bytes memory cancelOrderMessage = Messages.formatCancelInvestOrder( + lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId + ); + assertEq(cancelOrderMessage, router.values_bytes("send")); + + centrifugeChain.isExecutedDecreaseInvestOrder( + lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId, uint128(amount), 0 + ); + assertEq(erc20.balanceOf(address(escrow)), 0); + assertEq(erc20.balanceOf(address(userEscrow)), amount); + assertEq(erc20.balanceOf(self), 0); + assertEq(lPool.maxRedeem(self), amount); + assertEq(lPool.maxWithdraw(self), amount); + } +} diff --git a/test/integration/Mint.t.sol b/test/integration/Mint.t.sol new file mode 100644 index 00000000..f3749fdd --- /dev/null +++ b/test/integration/Mint.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import "./../TestSetup.t.sol"; + +contract MintTest is TestSetup { + function testMint(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + + TrancheTokenLike trancheToken = TrancheTokenLike(address(lPool.share())); + root.denyContract(address(trancheToken), self); + + vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); + trancheToken.mint(investor, amount); + centrifugeChain.updateMember(lPool.poolId(), lPool.trancheId(), investor, type(uint64).max); + + vm.expectRevert(bytes("Auth/not-authorized")); + trancheToken.mint(investor, amount); + + root.relyContract(address(trancheToken), self); // give self auth permissions + + // success + trancheToken.mint(investor, amount); + assertEq(lPool.balanceOf(investor), amount); + assertEq(lPool.balanceOf(investor), trancheToken.balanceOf(investor)); + } +} diff --git a/test/integration/Redeem.t.sol b/test/integration/Redeem.t.sol new file mode 100644 index 00000000..75d50e43 --- /dev/null +++ b/test/integration/Redeem.t.sol @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.21; + +import "./../TestSetup.t.sol"; + +contract RedeemTest is TestSetup { + function testRedeem(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128 / 2)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + deposit(lPool_, self, amount); // deposit funds first + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice, uint64(block.timestamp) + ); + + // success + lPool.requestRedeem(amount, address(this), address(this)); + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(lPool.pendingRedeemRequest(self), amount); + + // fail: no tokens left + vm.expectRevert(bytes("LiquidityPool/insufficient-balance")); + lPool.requestRedeem(amount, address(this), address(this)); + + // trigger executed collectRedeem + uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId + uint128 currencyPayout = uint128((amount * 10 ** 18) / defaultPrice); + centrifugeChain.isExecutedCollectRedeem( + lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), _currencyId, currencyPayout, uint128(amount), 0 + ); + + // assert withdraw & redeem values adjusted + assertEq(lPool.maxWithdraw(self), currencyPayout); // max deposit + assertEq(lPool.maxRedeem(self), amount); // max deposit + assertEq(lPool.pendingRedeemRequest(self), 0); + assertEq(lPool.balanceOf(address(escrow)), 0); + assertEq(erc20.balanceOf(address(userEscrow)), currencyPayout); + + // success + lPool.redeem(amount / 2, self, self); // redeem half the amount to own wallet + + // fail -> investor has no approval to receive funds + vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); + lPool.redeem(amount / 2, investor, self); // redeem half the amount to another wallet + + // fail -> receiver needs to have max approval + erc20.approve(investor, lPool.maxRedeem(self)); + vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); + lPool.redeem(amount / 2, investor, self); // redeem half the amount to investor wallet + + // success + erc20.approve(investor, type(uint256).max); + lPool.redeem(amount / 2, investor, self); // redeem half the amount to investor wallet + + assertEq(lPool.balanceOf(self), 0); + assertTrue(lPool.balanceOf(address(escrow)) <= 1); + assertTrue(erc20.balanceOf(address(userEscrow)) <= 1); + + assertApproxEqAbs(erc20.balanceOf(self), (amount / 2), 1); + assertApproxEqAbs(erc20.balanceOf(investor), (amount / 2), 1); + assertTrue(lPool.maxWithdraw(self) <= 1); + assertTrue(lPool.maxRedeem(self) <= 1); + } + + function testWithdraw(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128 / 2)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + + deposit(lPool_, self, amount); // deposit funds first + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice, uint64(block.timestamp) + ); + + lPool.requestRedeem(amount, address(this), address(this)); + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(erc20.balanceOf(address(userEscrow)), 0); + assertGt(lPool.pendingRedeemRequest(self), 0); + + // trigger executed collectRedeem + uint128 _currencyId = poolManager.currencyAddressToId(address(erc20)); // retrieve currencyId + uint128 currencyPayout = uint128((amount * 10 ** 18) / defaultPrice); + centrifugeChain.isExecutedCollectRedeem( + lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), _currencyId, currencyPayout, uint128(amount), 0 + ); + + // assert withdraw & redeem values adjusted + assertEq(lPool.maxWithdraw(self), currencyPayout); // max deposit + assertEq(lPool.maxRedeem(self), amount); // max deposit + assertEq(lPool.balanceOf(address(escrow)), 0); + assertEq(erc20.balanceOf(address(userEscrow)), currencyPayout); + + lPool.withdraw(amount / 2, self, self); // withdraw half the amount + + // fail -> investor has no approval to receive funds + vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); + lPool.withdraw(amount / 2, investor, self); // redeem half the amount to another wallet + + // fail -> receiver needs to have max approval + erc20.approve(investor, lPool.maxWithdraw(self)); + vm.expectRevert(bytes("UserEscrow/receiver-has-no-allowance")); + lPool.withdraw(amount / 2, investor, self); // redeem half the amount to investor wallet + + // success + erc20.approve(investor, type(uint256).max); + lPool.withdraw(amount / 2, investor, self); // redeem half the amount to investor wallet + + assertTrue(lPool.balanceOf(self) <= 1); + assertTrue(erc20.balanceOf(address(userEscrow)) <= 1); + assertApproxEqAbs(erc20.balanceOf(self), currencyPayout / 2, 1); + assertApproxEqAbs(erc20.balanceOf(investor), currencyPayout / 2, 1); + assertTrue(lPool.maxRedeem(self) <= 1); + assertTrue(lPool.maxWithdraw(self) <= 1); + } + + function testRedeemWithApproval(uint256 redemption1, uint256 redemption2) public { + redemption1 = uint128(bound(redemption1, 2, MAX_UINT128 / 4)); + redemption2 = uint128(bound(redemption2, 2, MAX_UINT128 / 4)); + uint256 amount = redemption1 + redemption2; + vm.assume(amountAssumption(amount)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + + deposit(lPool_, investor, amount); // deposit funds first // deposit funds first + + // investor can requestRedeem + vm.prank(investor); + lPool.requestRedeem(amount, investor, investor); + + uint128 tokenAmount = uint128(lPool.balanceOf(address(escrow))); + centrifugeChain.isExecutedCollectRedeem( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(investor)), + defaultCurrencyId, + uint128(amount), + uint128(tokenAmount), + 0 + ); + + assertEq(lPool.maxRedeem(investor), tokenAmount); + assertEq(lPool.maxWithdraw(investor), uint128(amount)); + + // test for both scenarios redeem & withdraw + + // fail: self cannot redeem for investor + vm.expectRevert(bytes("LiquidityPool/not-the-operator")); + lPool.redeem(redemption1, investor, investor); + vm.expectRevert(bytes("LiquidityPool/not-the-operator")); + lPool.withdraw(redemption1, investor, investor); + + // fail: ward can not make requests on behalf of investor + root.relyContract(lPool_, self); + vm.expectRevert(bytes("LiquidityPool/not-the-operator")); + lPool.redeem(redemption1, investor, investor); + vm.expectRevert(bytes("LiquidityPool/not-the-operator")); + lPool.withdraw(redemption1, investor, investor); + + // investor redeems rest for himself + vm.prank(investor); + lPool.redeem(redemption1, investor, investor); + vm.prank(investor); + lPool.withdraw(redemption2, investor, investor); + } + + function testCancelRedeemOrder(uint256 amount) public { + amount = uint128(bound(amount, 2, MAX_UINT128)); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + deposit(lPool_, self, amount); // deposit funds first + + lPool.requestRedeem(amount, address(this), address(this)); + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(lPool.balanceOf(self), 0); + + // check message was send out to centchain + lPool.cancelRedeemRequest(); + bytes memory cancelOrderMessage = Messages.formatCancelRedeemOrder( + lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId + ); + assertEq(cancelOrderMessage, router.values_bytes("send")); + + centrifugeChain.isExecutedDecreaseRedeemOrder( + lPool.poolId(), lPool.trancheId(), _addressToBytes32(self), defaultCurrencyId, uint128(amount), 0 + ); + + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(lPool.balanceOf(self), 0); + assertEq(lPool.maxDeposit(self), amount); + assertEq(lPool.maxMint(self), amount); + } + + function testDecreaseRedeemRequest(uint256 amount, uint256 decreaseAmount) public { + decreaseAmount = uint128(bound(decreaseAmount, 2, MAX_UINT128 - 1)); + amount = uint128(bound(amount, decreaseAmount + 1, MAX_UINT128)); // amount > decreaseAmount + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + centrifugeChain.updateTrancheTokenPrice( + lPool.poolId(), lPool.trancheId(), defaultCurrencyId, defaultPrice, uint64(block.timestamp) + ); + deposit(lPool_, self, amount); + lPool.requestRedeem(amount, address(this), address(this)); + + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(lPool.balanceOf(self), 0); + + // decrease redeem request + lPool.decreaseRedeemRequest(decreaseAmount); + centrifugeChain.isExecutedDecreaseRedeemOrder( + lPool.poolId(), lPool.trancheId(), bytes32(bytes20(self)), defaultCurrencyId, uint128(decreaseAmount), 0 + ); + + assertEq(lPool.balanceOf(address(escrow)), amount); + assertEq(lPool.balanceOf(self), 0); + assertEq(lPool.maxDeposit(self), decreaseAmount); + assertEq(lPool.maxMint(self), decreaseAmount); + } + + function testTriggerIncreaseRedeemOrderTokens(uint128 amount) public { + amount = uint128(bound(amount, 2, (MAX_UINT128 - 1))); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + deposit(lPool_, investor, amount, false); // request and execute deposit, but don't claim + uint256 investorBalanceBefore = erc20.balanceOf(investor); + assertEq(lPool.maxMint(investor), amount); + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); + + vm.prank(investor); + lPool.mint(amount / 2, investor); // investor mints half of the amount + + assertApproxEqAbs(lPool.balanceOf(investor), amount / 2, 1); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), amount / 2, 1); + assertApproxEqAbs(lPool.maxMint(investor), amount / 2, 1); + + // Fail - Redeem amount too big + vm.expectRevert(bytes("ERC20/insufficient-balance")); + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, uint128(amount + 1)); + + //Fail - Tranche token amount zero + vm.expectRevert(bytes("InvestmentManager/tranche-token-amount-is-zero")); + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, 0); + + // should work even if investor is frozen + centrifugeChain.freeze(poolId, trancheId, investor); // freeze investor + assertTrue(!TrancheToken(address(lPool.share())).checkTransferRestriction(investor, address(escrow), amount)); + + // half of the amount will be trabsferred from the investor's wallet & half of the amount will be taken from + // escrow + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, amount); + + assertApproxEqAbs(lPool.balanceOf(investor), 0, 1); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), amount, 1); + assertEq(lPool.maxMint(investor), 0); + + centrifugeChain.isExecutedCollectRedeem( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(investor)), + defaultCurrencyId, + uint128(amount), + uint128(amount), + uint128(amount) + ); + + assertApproxEqAbs(lPool.balanceOf(address(escrow)), 0, 1); + assertApproxEqAbs(erc20.balanceOf(address(userEscrow)), amount, 1); + vm.prank(investor); + lPool.redeem(amount, investor, investor); + assertApproxEqAbs(erc20.balanceOf(investor), investorBalanceBefore + amount, 1); + } + + function testTriggerIncreaseRedeemOrderTokensUnmitedTokensInEscrow(uint128 amount) public { + amount = uint128(bound(amount, 2, (MAX_UINT128 - 1))); + + address lPool_ = deploySimplePool(); + LiquidityPool lPool = LiquidityPool(lPool_); + deposit(lPool_, investor, amount, false); // request and execute deposit, but don't claim + uint256 investorBalanceBefore = erc20.balanceOf(investor); + assertEq(lPool.maxMint(investor), amount); + uint64 poolId = lPool.poolId(); + bytes16 trancheId = lPool.trancheId(); + + // Fail - Redeem amount too big + vm.expectRevert(bytes("ERC20/insufficient-balance")); + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, uint128(amount + 1)); + + // should work even if investor is frozen + centrifugeChain.freeze(poolId, trancheId, investor); // freeze investor + assertTrue(!TrancheToken(address(lPool.share())).checkTransferRestriction(investor, address(escrow), amount)); + + // Test trigger partial redeem (maxMint > redeemAmount), where investor did not mint their tokens - user tokens + // are still locked in escrow + uint128 redeemAmount = uint128(amount / 2); + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, redeemAmount); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), amount, 1); + assertEq(lPool.balanceOf(investor), 0); + + // Test trigger full redeem (maxMint = redeemAmount), where investor did not mint their tokens - user tokens are + // still locked in escrow + redeemAmount = uint128(amount - redeemAmount); + centrifugeChain.triggerIncreaseRedeemOrder(poolId, trancheId, investor, defaultCurrencyId, redeemAmount); + assertApproxEqAbs(lPool.balanceOf(address(escrow)), amount, 1); + assertEq(lPool.balanceOf(investor), 0); + assertEq(lPool.maxMint(investor), 0); + + centrifugeChain.isExecutedCollectRedeem( + lPool.poolId(), + lPool.trancheId(), + bytes32(bytes20(investor)), + defaultCurrencyId, + uint128(amount), + uint128(amount), + uint128(amount) + ); + + assertApproxEqAbs(lPool.balanceOf(address(escrow)), 0, 1); + assertApproxEqAbs(erc20.balanceOf(address(userEscrow)), amount, 1); + vm.prank(investor); + lPool.redeem(amount, investor, investor); + + assertApproxEqAbs(erc20.balanceOf(investor), investorBalanceBefore + amount, 1); + } +} diff --git a/test/invariants/handlers/Investor.sol b/test/invariants/handlers/Investor.sol index ca9e45f4..0d57b6ae 100644 --- a/test/invariants/handlers/Investor.sol +++ b/test/invariants/handlers/Investor.sol @@ -18,7 +18,7 @@ interface LiquidityPoolLike is IERC4626 { function requestDeposit(uint256 assets, address owner) external; function requestRedeem(uint256 shares, address owner) external; function share() external view returns (address); - function investmentManager() external view returns (address); + function manager() external view returns (address); } contract InvestorHandler is Test { @@ -67,7 +67,7 @@ contract InvestorHandler is Test { erc20 = ERC20Like(erc20_); trancheToken = ERC20Like(liquidityPool.share()); escrow = escrow_; - investmentManager = liquidityPool.investmentManager(); + investmentManager = liquidityPool.manager(); } // --- Investments --- @@ -101,7 +101,7 @@ contract InvestorHandler is Test { bound( amount, 0, - MathLib.min( + _min( // Don't allow total outstanding redeem requests > type(uint128).max uint128(type(uint128).max - totalRedeemRequested + totalTrancheTokensPaidOutOnRedeem), // Cannot redeem more than current balance of TT @@ -197,4 +197,9 @@ contract InvestorHandler is Test { totalTrancheTokensPaidOutOnRedeem += trancheTokenPayout; totalCurrencyPaidOutOnRedeem += currencyPayout; } + + /// @notice Returns the smallest of two numbers. + function _min(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? b : a; + } } diff --git a/test/mock/MockCentrifugeChain.sol b/test/mock/MockCentrifugeChain.sol index 6071c8f0..151050d9 100644 --- a/test/mock/MockCentrifugeChain.sol +++ b/test/mock/MockCentrifugeChain.sol @@ -70,8 +70,14 @@ contract MockCentrifugeChain is Test { router.execute(_message); } - function updateTrancheTokenPrice(uint64 poolId, bytes16 trancheId, uint128 currencyId, uint128 price) public { - bytes memory _message = Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price); + function updateTrancheTokenPrice( + uint64 poolId, + bytes16 trancheId, + uint128 currencyId, + uint128 price, + uint64 computedAt + ) public { + bytes memory _message = Messages.formatUpdateTrancheTokenPrice(poolId, trancheId, currencyId, price, computedAt); router.execute(_message); } diff --git a/test/token/RestrictionManager.t.sol b/test/token/RestrictionManager.t.sol index aece12da..704eb205 100644 --- a/test/token/RestrictionManager.t.sol +++ b/test/token/RestrictionManager.t.sol @@ -27,4 +27,15 @@ contract RestrictionManagerTest is Test { restrictionManager.updateMember(address(this), validUntil); assert(restrictionManager.hasMember(address(this))); } + + function testFreeze() public { + restrictionManager.freeze(address(this)); + assertEq(restrictionManager.frozen(address(this)), 1); + } + + function testFreezingZeroAddress() public { + vm.expectRevert("RestrictionManager/cannot-freeze-zero-address"); + restrictionManager.freeze(address(0)); + assertEq(restrictionManager.frozen(address(0)), 0); + } } diff --git a/test/token/Tranche.t.sol b/test/token/Tranche.t.sol index ab6140bb..23a5ae33 100644 --- a/test/token/Tranche.t.sol +++ b/test/token/Tranche.t.sol @@ -14,6 +14,8 @@ contract TrancheTokenTest is Test { RestrictionManager restrictionManager; address self; + address targetUser = makeAddr("targetUser"); + address randomUser = makeAddr("random"); function setUp() public { self = address(this); @@ -73,11 +75,9 @@ contract TrancheTokenTest is Test { token.removeTrustedForwarder(self); } - function testCheckTrustedForwarderWorks(uint256 validUntil, uint256 amount, address random) public { + function testCheckTrustedForwarderWorks(uint256 validUntil, uint256 amount) public { vm.assume(validUntil > block.timestamp); vm.assume(amount > 0); - vm.assume(random != address(0)); - vm.assume(random != address(token)); assertTrue(!token.isTrustedForwarder(self)); // make self trusted forwarder @@ -85,13 +85,13 @@ contract TrancheTokenTest is Test { assertTrue(token.isTrustedForwarder(self)); // add self to restrictionManager restrictionManager.updateMember(self, validUntil); - restrictionManager.updateMember(random, validUntil); + restrictionManager.updateMember(randomUser, validUntil); bool success; // test auth works with trustedForwarder - // fail -> random not ward + // fail -> randomUser not ward (success,) = address(token).call( - abi.encodeWithSelector(bytes4(keccak256(bytes("mint(address,uint256)"))), self, amount, random) + abi.encodeWithSelector(bytes4(keccak256(bytes("mint(address,uint256)"))), self, amount, randomUser) ); assertTrue(!success); assertEq(token.balanceOf(self), 0); @@ -104,9 +104,9 @@ contract TrancheTokenTest is Test { assertEq(token.balanceOf(self), amount); // test non auth function works with trusted forwarder - // fail -> random has no balance + // fail -> randomUser has no balance (success,) = address(token).call( - abi.encodeWithSelector(bytes4(keccak256(bytes("transfer(address,uint256)"))), self, amount, random) + abi.encodeWithSelector(bytes4(keccak256(bytes("transfer(address,uint256)"))), self, amount, randomUser) ); assertTrue(!success); @@ -114,17 +114,17 @@ contract TrancheTokenTest is Test { // success -> self has enough balance to transfer (success,) = address(token).call( - abi.encodeWithSelector(bytes4(keccak256(bytes("transfer(address,uint256)"))), random, amount, self) + abi.encodeWithSelector(bytes4(keccak256(bytes("transfer(address,uint256)"))), randomUser, amount, self) ); assertTrue(success); assertEq(token.balanceOf(self), 0); - assertEq(token.balanceOf(random), amount); + assertEq(token.balanceOf(randomUser), amount); } // --- RestrictionManager --- // transferFrom - function testTransferFromTokensToMemberWorks(uint256 amount, address targetUser, uint256 validUntil) public { + function testTransferFromTokensToMemberWorks(uint256 amount, uint256 validUntil) public { vm.assume(baseAssumptions(validUntil, targetUser)); mint(self, amount, validUntil); @@ -145,9 +145,7 @@ contract TrancheTokenTest is Test { assertEq(token.balanceOf(targetUser), amount); } - function testTransferFromTokensToExpiredMemberFails(uint256 amount, address targetUser, uint256 validUntil) - public - { + function testTransferFromTokensToExpiredMemberFails(uint256 amount, uint256 validUntil) public { vm.assume(baseAssumptions(validUntil, targetUser)); restrictionManager.updateMember(targetUser, block.timestamp); @@ -161,7 +159,7 @@ contract TrancheTokenTest is Test { } // Transfer - function testTransferTokensToMemberWorks(uint256 amount, address targetUser, uint256 validUntil) public { + function testTransferTokensToMemberWorks(uint256 amount, uint256 validUntil) public { vm.assume(baseAssumptions(validUntil, targetUser)); mint(self, amount, validUntil); @@ -182,7 +180,7 @@ contract TrancheTokenTest is Test { assertEq(token.balanceOf(targetUser), amount); } - function testTransferTokensToExpiredMemberFails(uint256 amount, address targetUser, uint256 validUntil) public { + function testTransferTokensToExpiredMemberFails(uint256 amount, uint256 validUntil) public { vm.assume(baseAssumptions(validUntil, targetUser)); restrictionManager.updateMember(targetUser, block.timestamp); @@ -197,7 +195,7 @@ contract TrancheTokenTest is Test { } // Mint - function testMintTokensToMemberWorks(uint256 amount, address targetUser, uint256 validUntil) public { + function testMintTokensToMemberWorks(uint256 amount, uint256 validUntil) public { vm.assume(baseAssumptions(validUntil, targetUser)); // mint fails -> self not a member @@ -217,9 +215,7 @@ contract TrancheTokenTest is Test { assertEq(token.balanceOf(targetUser), amount); } - function testMintTokensToExpiredMemberFails(uint256 amount, address targetUser) public { - vm.assume(targetUser != address(0) && targetUser != self && targetUser != address(token)); - + function testMintTokensToExpiredMemberFails(uint256 amount) public { restrictionManager.updateMember(targetUser, block.timestamp); assertEq(restrictionManager.members(targetUser), block.timestamp); @@ -230,17 +226,18 @@ contract TrancheTokenTest is Test { (token.balanceOf(targetUser), 0); } - function mint(address targetUser, uint256 amount, uint256 validUntil) public { + function mint(address user, uint256 amount, uint256 validUntil) public { vm.expectRevert(bytes("RestrictionManager/destination-not-a-member")); - token.mint(targetUser, amount); + token.mint(user, amount); - restrictionManager.updateMember(targetUser, validUntil); - assertEq(restrictionManager.members(targetUser), validUntil); - token.mint(targetUser, amount); + restrictionManager.updateMember(user, validUntil); + assertEq(restrictionManager.members(user), validUntil); + token.mint(user, amount); } // Auth transfer - function testAuthTransferFrom(uint256 amount, address sourceUser, uint256 validUntil) public { + function testAuthTransferFrom(uint256 amount, uint256 validUntil) public { + address sourceUser = makeAddr("sourceUser"); vm.assume(baseAssumptions(validUntil, sourceUser)); restrictionManager.updateMember(sourceUser, validUntil);