diff --git a/script/Deployer.sol b/script/Deployer.sol index fd55b93c..332a70ee 100644 --- a/script/Deployer.sol +++ b/script/Deployer.sol @@ -9,6 +9,7 @@ import {TrancheFactory} from "src/factories/TrancheFactory.sol"; import {ERC7540VaultFactory} from "src/factories/ERC7540VaultFactory.sol"; import {RestrictionManager} from "src/token/RestrictionManager.sol"; import {TransferProxyFactory} from "src/factories/TransferProxyFactory.sol"; +import {VaultProxyFactory} from "src/factories/VaultProxyFactory.sol"; import {PoolManager} from "src/PoolManager.sol"; import {Escrow} from "src/Escrow.sol"; import {CentrifugeRouter} from "src/CentrifugeRouter.sol"; @@ -35,6 +36,7 @@ contract Deployer is Script { address public restrictionManager; address public trancheFactory; address public transferProxyFactory; + address public vaultProxyFactory; function deploy(address deployer) public { // If no salt is provided, a pseudo-random salt is generated, @@ -60,6 +62,7 @@ contract Deployer is Script { gasService = new GasService(messageCost, proofCost, gasPrice, tokenPrice); gateway = new Gateway(address(root), address(poolManager), address(investmentManager), address(gasService)); router = new CentrifugeRouter(address(routerEscrow), address(gateway), address(poolManager)); + vaultProxyFactory = address(new VaultProxyFactory{salt: salt}(address(router))); guardian = new Guardian(adminSafe, address(root), address(gateway)); _endorse(); diff --git a/src/factories/VaultProxyFactory.sol b/src/factories/VaultProxyFactory.sol new file mode 100644 index 00000000..9eeb1ef1 --- /dev/null +++ b/src/factories/VaultProxyFactory.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.26; + +import {SafeTransferLib} from "src/libraries/SafeTransferLib.sol"; +import {ICentrifugeRouter} from "src/interfaces/ICentrifugeRouter.sol"; +import {IERC20} from "src/interfaces/IERC20.sol"; +import {IERC7540Vault} from "src/interfaces/IERC7540.sol"; +import {IVaultProxy, IVaultProxyFactory} from "src/interfaces/factories/IVaultProxy.sol"; + +contract VaultProxy is IVaultProxy { + IERC20 public immutable asset; + IERC20 public immutable share; + address public immutable user; + address public immutable vault; + ICentrifugeRouter public immutable router; + + constructor(address router_, address vault_, address user_) { + asset = IERC20(IERC7540Vault(vault_).asset()); + share = IERC20(IERC7540Vault(vault_).share()); + user = user_; + vault = vault_; + router = ICentrifugeRouter(router_); + } + + /// @inheritdoc IVaultProxy + function requestDeposit() external payable { + uint256 assets = asset.allowance(user, address(this)); + require(assets > 0, "VaultProxy/zero-asset-allowance"); + asset.transferFrom(user, address(router), assets); + router.requestDeposit{value: msg.value}(vault, assets, user, address(router), msg.value); + } + + /// @inheritdoc IVaultProxy + function claimDeposit() external { + uint256 maxMint = IERC7540Vault(vault).maxMint(user); + IERC7540Vault(vault).mint(maxMint, address(user), user); + } + + /// @inheritdoc IVaultProxy + function requestRedeem() external payable { + uint256 shares = share.allowance(user, user); + require(shares > 0, "VaultProxy/zero-share-allowance"); + share.transferFrom(user, address(router), shares); + router.requestRedeem{value: msg.value}(vault, shares, user, address(router), msg.value); + } + + /// @inheritdoc IVaultProxy + function claimRedeem() external { + uint256 maxWithdraw = IERC7540Vault(vault).maxWithdraw(address(this)); + IERC7540Vault(vault).withdraw(maxWithdraw, address(user), address(this)); + } +} + +interface VaultProxyFactoryLike { + function newVaultProxy(address poolManager, bytes32 destination) external returns (address); +} + +/// @title Vault investment proxy factory +/// @notice Used to deploy vault proxies that investors can give ERC20 approval for assets or shares +/// which anyone can then permissionlessly trigger the requests to the vaults to. Can be used +/// by integrations that can only support ERC20 approvals and not arbitrary contract calls. +contract VaultProxyFactory is IVaultProxyFactory { + address public immutable router; + + /// @inheritdoc IVaultProxyFactory + mapping(bytes32 id => address proxy) public proxies; + + constructor(address router_) { + router = router_; + } + + /// @inheritdoc IVaultProxyFactory + function newVaultProxy(address vault, address user) public returns (address) { + bytes32 id = keccak256(abi.encodePacked(vault, user)); + require(proxies[id] == address(0), "VaultProxyFactory/proxy-already-deployed"); + + address proxy = address(new VaultProxy(router, vault, user)); + proxies[id] = proxy; + + emit DeployVaultProxy(vault, user, proxy); + return proxy; + } +} diff --git a/src/interfaces/factories/IVaultProxy.sol b/src/interfaces/factories/IVaultProxy.sol new file mode 100644 index 00000000..59cec7fc --- /dev/null +++ b/src/interfaces/factories/IVaultProxy.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.5.0; + +interface IVaultProxy { + /// @dev Anyone can submit deposit request if there is USDC approval + function requestDeposit() external payable; + + /// @dev Anyone can claim shares + function claimDeposit() external; + + /// @dev Anyone can submit redeem request if there is share token approval + function requestRedeem() external payable; + + /// @dev Anyone can claim assets + function claimRedeem() external; +} + +interface IVaultProxyFactory { + event DeployVaultProxy(address indexed vault, address indexed user, address proxy); + + /// @dev Lookup proxy by keccak256(vault,user) + function proxies(bytes32 id) external view returns (address proxy); + + /// @dev Deploy new vault proxy + function newVaultProxy(address vault, address user) external returns (address); +} diff --git a/test/mocks/MockCentrifugeRouter.sol b/test/mocks/MockCentrifugeRouter.sol new file mode 100644 index 00000000..c6c0cba4 --- /dev/null +++ b/test/mocks/MockCentrifugeRouter.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import "./Mock.sol"; + +contract MockCentrifugeRouter is Mock { + function requestDeposit(address vault, uint256 amount, address controller, address owner, uint256 topUpAmount) + external + payable + { + values_address["requestDeposit_vault"] = vault; + values_uint256["requestDeposit_amount"] = amount; + values_address["requestDeposit_controller"] = controller; + values_address["requestDeposit_owner"] = owner; + values_uint256["requestDeposit_topUpAmount"] = topUpAmount; + } + + function requestRedeem(address vault, uint256 amount, address controller, address owner, uint256 topUpAmount) + external + payable + { + values_address["requestRedeem_vault"] = vault; + values_uint256["requestRedeem_amount"] = amount; + values_address["requestRedeem_controller"] = controller; + values_address["requestRedeem_owner"] = owner; + values_uint256["requestRedeem_topUpAmount"] = topUpAmount; + } + + // Added to be ignored in coverage report + function test() public {} +} diff --git a/test/mocks/MockVault.sol b/test/mocks/MockVault.sol new file mode 100644 index 00000000..ef322ede --- /dev/null +++ b/test/mocks/MockVault.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import "./Mock.sol"; + +contract MockVault is Mock { + address public immutable asset; + address public immutable share; + + constructor(address asset_, address share_) { + asset = asset_; + share = share_; + } + + // Added to be ignored in coverage report + function test() public {} +} diff --git a/test/unit/factories/VaultProxyFactory.t.sol b/test/unit/factories/VaultProxyFactory.t.sol new file mode 100644 index 00000000..10ef3691 --- /dev/null +++ b/test/unit/factories/VaultProxyFactory.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.26; + +import {VaultProxy, VaultProxyFactory} from "src/factories/VaultProxyFactory.sol"; +import {ERC20} from "src/token/ERC20.sol"; +import {IERC7540Vault} from "src/interfaces/IERC7540.sol"; +import "test/BaseTest.sol"; + +contract VaultProxyFactoryTest is BaseTest { + IERC7540Vault vault; + ERC20 asset = new ERC20(18); + ERC20 share = new ERC20(18); + + function testVaultProxyCreation(address user) public { + vault = IERC7540Vault(deployVault(1, 18, restrictionManager, "", "", "1", 1, address(asset))); + + VaultProxy proxy = VaultProxy(VaultProxyFactory(vaultProxyFactory).newVaultProxy(address(vault), user)); + assertEq(VaultProxyFactory(vaultProxyFactory).router(), address(router)); + assertEq( + VaultProxyFactory(vaultProxyFactory).proxies(keccak256(abi.encodePacked(address(vault), user))), + address(proxy) + ); + assertEq(address(proxy.router()), address(router)); + assertEq(proxy.vault(), address(vault)); + assertEq(proxy.user(), user); + + // Proxies cannot be deployed twice + vm.expectRevert(bytes("VaultProxyFactory/proxy-already-deployed")); + VaultProxyFactory(vaultProxyFactory).newVaultProxy(address(vault), user); + } + + function testVaultProxyDeposit(uint256 amount) public { + amount = bound(amount, 1, type(uint128).max); + + vault = IERC7540Vault(deployVault(1, 18, restrictionManager, "", "", "1", 1, address(asset))); + address user = makeAddr("user"); + + VaultProxy proxy = VaultProxy(VaultProxyFactory(vaultProxyFactory).newVaultProxy(address(vault), user)); + centrifugeChain.updateMember(vault.poolId(), vault.trancheId(), user, type(uint64).max); + + asset.mint(user, amount); + vm.deal(address(this), 1 ether); + + vm.expectRevert(bytes("VaultProxy/zero-asset-allowance")); + proxy.requestDeposit(); + + assertEq(asset.balanceOf(user), amount); + assertEq(asset.balanceOf(address(escrow)), 0); + + vm.prank(user); + asset.approve(address(proxy), amount); + + proxy.requestDeposit{value: 1 ether}(); + + assertEq(asset.balanceOf(user), 0); + assertEq(asset.balanceOf(address(escrow)), amount); + + centrifugeChain.isFulfilledDepositRequest( + vault.poolId(), vault.trancheId(), bytes32(bytes20(user)), 1, uint128(amount), uint128(amount) + ); + + proxy.claimDeposit(); + assertEq(share.balanceOf(user), amount); + } + + function testVaultProxyRedeem(uint256 amount) public { + amount = bound(amount, 1, type(uint128).max); + + vault = IERC7540Vault(deployVault(1, 18, restrictionManager, "", "", "1", 1, address(asset))); + address user = makeAddr("user"); + + VaultProxy proxy = VaultProxy(VaultProxyFactory(vaultProxyFactory).newVaultProxy(address(vault), user)); + centrifugeChain.updateMember(vault.poolId(), vault.trancheId(), address(proxy), type(uint64).max); + + share.mint(user, amount); + vm.deal(address(this), 1 ether); + + vm.expectRevert(bytes("VaultProxy/zero-share-allowance")); + proxy.requestRedeem(); + + assertEq(share.balanceOf(user), amount); + assertEq(share.balanceOf(address(router)), 0); + + vm.prank(user); + share.approve(address(proxy), amount); + + proxy.requestRedeem{value: 1 ether}(); + + assertEq(share.balanceOf(user), 0); + assertEq(share.balanceOf(address(router)), amount); + + // assertEq(router.values_address("requestRedeem_vault"), address(vault)); + // assertEq(router.values_uint256("requestRedeem_amount"), amount); + // assertEq(router.values_address("requestRedeem_controller"), user); + // assertEq(router.values_address("requestRedeem_owner"), address(router)); + // assertEq(router.values_uint256("requestRedeem_topUpAmount"), 1 ether); + } +}