diff --git a/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol b/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol index 152d9e2..adc228e 100644 --- a/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol +++ b/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol @@ -158,16 +158,14 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR /** @inheritdoc ERC4626*/ function totalAssets() public view override returns (uint256) { uint256 additionalAssets; + // Note: assuming aToken:token ratio is always 1:1 + // aToken balance is always part of total assets (deposits in POOLING, repayments in RUNNING/ENDING) + if (aAsset != address(0)) { + additionalAssets = IERC20(aAsset).balanceOf(address(this)); + } Stage _stage = stage(); - if (_stage == Stage.POOLING) { - if (aAsset != address(0)) { - // Note: assuming aToken:token ratio is always 1:1 - additionalAssets = IERC20(aAsset).balanceOf(address(this)); - } - } else if (_stage == Stage.RUNNING) { - if (loanContract.getLOANStatus(loanId) == LOANStatus.RUNNING) { - additionalAssets = loanContract.getLOANDebt(loanId); - } + if (_stage == Stage.RUNNING && loanContract.getLOANStatus(loanId) == LOANStatus.RUNNING) { + additionalAssets += loanContract.getLOANDebt(loanId); } return _availableLiquidity() + additionalAssets; } @@ -193,7 +191,7 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR max = _convertToAssets(balanceOf(owner), Math.Rounding.Down); if (_stage == Stage.RUNNING) { - max = Math.min(max, _availableLiquidity()); + max = Math.min(max, _totalAvailableLiquidity()); } } @@ -201,7 +199,7 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR function maxRedeem(address owner) public view override returns (uint256 max) { max = balanceOf(owner); if (stage() == Stage.RUNNING) { - max = Math.min(max, _convertToShares(_availableLiquidity(), Math.Rounding.Down)); + max = Math.min(max, _convertToShares(_totalAvailableLiquidity(), Math.Rounding.Down)); } } @@ -278,6 +276,14 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR return IERC20(asset()).balanceOf(address(this)); } + function _totalAvailableLiquidity() internal view returns (uint256) { + uint256 liquidity = _availableLiquidity(); + if (aAsset != address(0)) { + liquidity += IERC20(aAsset).balanceOf(address(this)); + } + return liquidity; + } + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { super._deposit(caller, receiver, assets, shares); if (aAsset != address(0)) { @@ -286,8 +292,20 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR } function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal override { - if (aAsset != address(0) && stage() == Stage.POOLING) { - aave.withdraw(asset(), assets, address(this)); + if (aAsset != address(0)) { + Stage _stage = stage(); + if (_stage == Stage.POOLING) { + // During POOLING, all assets are in Aave + aave.withdraw(asset(), assets, address(this)); + } else if (_stage == Stage.RUNNING || _stage == Stage.ENDING) { + // During RUNNING/ENDING, repayments and unutilized capital are in Aave. + // Withdraw from Aave if we don't have enough cash. + // Use try/catch so withdrawals of available cash still work if Aave is down. + uint256 availableCash = _availableLiquidity(); + if (availableCash < assets) { + try aave.withdraw(asset(), assets - availableCash, address(this)) {} catch {} + } + } } super._withdraw(caller, receiver, owner, assets, shares); } @@ -366,7 +384,7 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR uint256 loanId_, address lender, address creditAddress, - uint256 /* principal */, + uint256 principal, bytes calldata lenderData ) external returns (bytes32) { require(msg.sender == address(loanContract)); @@ -378,7 +396,11 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR loanId = loanId_; if (aAsset != address(0)) { - aave.withdraw(asset(), type(uint256).max, address(this)); + // Withdraw only the principal needed for the loan, keep the rest earning yield in Aave + uint256 availableCash = _availableLiquidity(); + if (availableCash < principal) { + aave.withdraw(asset(), principal - availableCash, address(this)); + } } return LENDER_CREATE_HOOK_RETURN_VALUE; @@ -387,12 +409,19 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR /** @inheritdoc IPWNLenderRepaymentHook*/ function onLoanRepaid( address /* lender */, - address /* creditAddress */, - uint256 /* repayment */, + address creditAddress, + uint256 repayment, bytes calldata /* lenderData */ - ) external pure returns (bytes32) { - // Note: no need to validate anything, the hook only accepts repayments - // This guarantees that the loan unclaimed amount is always zero + ) external returns (bytes32) { + require(msg.sender == address(loanContract)); + + // Deposit repayment to Aave to earn yield while waiting for withdrawals/redemptions. + // Use try/catch to avoid triggering PWNLoan's unclaimedRepayment fallback if Aave is down. + // If supply fails, the repayment stays as cash in the vault. + if (aAsset != address(0)) { + try aave.supply(creditAddress, repayment, address(this), 0) {} catch {} + } + return LENDER_REPAYMENT_HOOK_RETURN_VALUE; } diff --git a/test/fork/PWNCrowdsourceLenderVault.fork.t.sol b/test/fork/PWNCrowdsourceLenderVault.fork.t.sol index 0d8d35b..3f4268c 100644 --- a/test/fork/PWNCrowdsourceLenderVault.fork.t.sol +++ b/test/fork/PWNCrowdsourceLenderVault.fork.t.sol @@ -14,7 +14,8 @@ import { T20 } from "test/helper/T20.sol"; contract PWNCrowdsourceLenderVaultForkTest is DeploymentTest { - uint256 ERR_DELTA = 0.001 ether; // 0.01% + uint256 ERR_DELTA = 0.001 ether; // 0.1% + uint256 ERR_DELTA_LIFECYCLE = 0.05 ether; // 5% - higher tolerance for lifecycle tests due to Aave yield on repayments IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); @@ -221,8 +222,10 @@ contract PWNCrowdsourceLenderVault_Pooling_ForkTest is PWNCrowdsourceLenderVault extra: "" }); - assertEq(lenderVault.totalAssets(), poolingTotalAssets); // no change in total assets - assertEq(IERC20(aUSDC).balanceOf(address(lenderVault)), 0); // no aave deposit after loan start + assertApproxEqRel(lenderVault.totalAssets(), poolingTotalAssets, ERR_DELTA); // no change in total assets + // Unutilized capital stays in Aave earning yield + uint256 expectedAaveBalance = initialAmount * lenders.length - acceptorValues.creditAmount; + assertApproxEqRel(IERC20(aUSDC).balanceOf(address(lenderVault)), expectedAaveBalance, ERR_DELTA); assertEq(USDC.balanceOf(borrower), acceptorValues.creditAmount); // borrower received the credit assertEq(lenderVault.totalCollateralAssets(), 0); assertEq(lenderVault.loanId(), loanId); @@ -307,7 +310,9 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault }); unutilizedAmount = initialAmount * lenders.length - acceptorValues.creditAmount; - assertApproxEqRel(IERC20(USDC).balanceOf(address(lenderVault)), unutilizedAmount, ERR_DELTA); // 20k + // Unutilized capital stays in Aave, not as USDC cash + assertApproxEqRel(IERC20(aUSDC).balanceOf(address(lenderVault)), unutilizedAmount, ERR_DELTA); // 20k in Aave + assertApproxEqRel(IERC20(USDC).balanceOf(address(lenderVault)), 0, ERR_DELTA); // no USDC cash } @@ -336,16 +341,25 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); + // Verify we have enough available liquidity for the withdrawals + // With repayments going to Aave, available = aToken balance (~30k after repayment) + uint256 maxWithdraw1 = lenderVault.maxWithdraw(lenders[1]); + assertGe(maxWithdraw1, 20_000e6); // Should be able to withdraw 20k + vm.prank(lenders[1]); lenderVault.withdraw(20_000e6, lenders[1], lenders[1]); expectedTotalAssets -= 20_000e6; assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); + // After 20k withdrawal, ~10k should remain in Aave (30k - 20k) + uint256 maxWithdraw2 = lenderVault.maxWithdraw(lenders[2]); + assertGe(maxWithdraw2, 9_999e6); // Should be able to withdraw ~10k (accounting for Aave rounding) + vm.prank(lenders[2]); - lenderVault.withdraw(10_000e6, lenders[2], lenders[2]); + lenderVault.withdraw(9_999e6, lenders[2], lenders[2]); // Withdraw slightly less to account for rounding - expectedTotalAssets -= 10_000e6; + expectedTotalAssets -= 9_999e6; assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); } @@ -456,7 +470,8 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault vm.warp(block.timestamp + terms.duration); // Note: defaulted loan - assertApproxEqRel(lenderVault.totalAssets(), unutilizedAmount, ERR_DELTA); + // Unutilized capital earns Aave yield, so totalAssets >= unutilizedAmount + assertGe(lenderVault.totalAssets(), unutilizedAmount); assertGt(lenderVault.totalCollateralAssets(), 0); assertGt(lenderVault.previewCollateralRedeem(50_000e6), 0); } @@ -502,7 +517,9 @@ contract PWNCrowdsourceLenderVault_Ending_ForkTest is PWNCrowdsourceLenderVaultF }); unutilizedAmount = initialAmount * lenders.length - acceptorValues.creditAmount; - assertApproxEqRel(IERC20(USDC).balanceOf(address(lenderVault)), unutilizedAmount, ERR_DELTA); // 20k + // Unutilized capital stays in Aave, not as USDC cash + assertApproxEqRel(IERC20(aUSDC).balanceOf(address(lenderVault)), unutilizedAmount, ERR_DELTA); // 20k in Aave + assertApproxEqRel(IERC20(USDC).balanceOf(address(lenderVault)), 0, ERR_DELTA); // no USDC cash } @@ -546,7 +563,8 @@ contract PWNCrowdsourceLenderVault_Ending_ForkTest is PWNCrowdsourceLenderVaultF vm.prank(lenders[i]); lenderVault.redeem(shares, lenders[i], lenders[i]); - assertApproxEqRel(USDC.balanceOf(lenders[i]), (unutilizedAmount + donation) / 4, ERR_DELTA); + // Lenders get at least their share of unutilized + donation (may be more due to Aave yield) + assertGe(USDC.balanceOf(lenders[i]), (unutilizedAmount + donation) / 4); assertApproxEqRel(WETH.balanceOf(lenders[i]), collAmount / 4, ERR_DELTA); } @@ -648,11 +666,12 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende totalLendersBalance += USDC.balanceOf(lender); } - // assert that all assts are claimed + // assert that all assets are claimed assertEq(lenderVault.totalSupply(), 0); // no shares left assertApproxEqAbs(lenderVault.totalAssets(), 0, 2); // no assets left (only dust) assertApproxEqAbs(lenderVault.totalCollateralAssets(), 0, 2); // no collateral left (only dust) - assertApproxEqRel(totalLendersBalance, repaidAmount + 20_000e6, ERR_DELTA); // all assets are claimed + // Note: lenders get more than repaidAmount + unutilized due to Aave yield on repayments + assertGe(totalLendersBalance, repaidAmount + 20_000e6); // all assets are claimed (may be higher due to Aave yield) } function test_fullLifecycle_whenDefaulted() external { @@ -717,11 +736,12 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende totalLendersCollateralBalance += WETH.balanceOf(lender); } - // assert that all assts are claimed + // assert that all assets are claimed assertEq(lenderVault.totalSupply(), 0); // no shares left assertApproxEqAbs(lenderVault.totalAssets(), 0, 2); // no assets left (only dust) assertApproxEqAbs(lenderVault.totalCollateralAssets(), 0, 2); // no collateral left (only dust) - assertApproxEqRel(totalLendersBalance, repaidAmount + 20_000e6, ERR_DELTA); // all assets are claimed + // Note: lenders get more than repaidAmount + unutilized due to Aave yield on repayments + assertGe(totalLendersBalance, repaidAmount + 20_000e6); // all assets are claimed (may be higher due to Aave yield) assertApproxEqRel(totalLendersCollateralBalance, loan_.collateral.amount, ERR_DELTA); // collateral is claimed } diff --git a/test/fork/PWNStableInterestProposal.fork.t.sol b/test/fork/PWNStableInterestProposal.fork.t.sol index 52ccbba..31e5b22 100644 --- a/test/fork/PWNStableInterestProposal.fork.t.sol +++ b/test/fork/PWNStableInterestProposal.fork.t.sol @@ -210,12 +210,13 @@ contract PWNStableProductForkTest is DeploymentTest { function test_twoFeeds_USDT_ARB() external { IERC20 ARB = IERC20(0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1); IERC20 USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7); + address ARB_USD_Feed = 0x31697852a68433DbCc2Ff612c516d69E3D9bd08F; address USDT_USD_Feed = 0x3E7d1eAB13ad0104d2750B8863b489D65364e32D; deal(lender, 10000 ether); deal(borrower, 10000 ether); - deal(address(ARB), borrower, 5000e18, false); + deal(address(ARB), borrower, 5_277e18, false); // USDT has non-standard storage layout, so we transfer from a known holder instead of using deal() vm.prank(USDT_HOLDER); (bool transferSuccess, ) = address(USDT).call(abi.encodeWithSignature("transfer(address,uint256)", lender, 1000e6)); diff --git a/test/unit/PWNCrowdsourceLenderVault.t.sol b/test/unit/PWNCrowdsourceLenderVault.t.sol index 05bfe81..12b6dbd 100644 --- a/test/unit/PWNCrowdsourceLenderVault.t.sol +++ b/test/unit/PWNCrowdsourceLenderVault.t.sol @@ -243,49 +243,49 @@ contract PWNCrowdsourceLenderVault_TotalAssets_Test is PWNCrowdsourceLenderVault _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _mockCreditBalance(address(crowdsource), 120 ether); - _mockAaveCreditBalance(address(crowdsource), 10 ether); // should not be used + _mockAaveCreditBalance(address(crowdsource), 10 ether); // repayments deposited to Aave _mockLoanRepaymentAmount(99 ether); _mockLoanStatus(2); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANStatus.selector)); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANDebt.selector)); - assertEq(crowdsource.totalAssets(), 120 ether + 99 ether); + assertEq(crowdsource.totalAssets(), 120 ether + 10 ether + 99 ether); } function test_shouldReturnLoanAndOwnedBalance_whenRunningStage_whenDefaultedLoan() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _mockCreditBalance(address(crowdsource), 120 ether); - _mockAaveCreditBalance(address(crowdsource), 10 ether); // should not be used - _mockLoanRepaymentAmount(99 ether); // should not be used + _mockAaveCreditBalance(address(crowdsource), 10 ether); // repayments deposited to Aave + _mockLoanRepaymentAmount(99 ether); // should not be used (loan defaulted) _mockLoanStatus(4); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANStatus.selector)); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANDebt.selector), 0); - assertEq(crowdsource.totalAssets(), 120 ether); + assertEq(crowdsource.totalAssets(), 120 ether + 10 ether); } function test_shouldReturnLoanAndOwnedBalance_whenRunningStage_whenRepaidLoan() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _mockCreditBalance(address(crowdsource), 120 ether); - _mockAaveCreditBalance(address(crowdsource), 10 ether); // should not be used - _mockLoanRepaymentAmount(99 ether); // should not be used + _mockAaveCreditBalance(address(crowdsource), 10 ether); // repayments deposited to Aave + _mockLoanRepaymentAmount(99 ether); // should not be used (loan repaid) _mockLoanStatus(3); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANStatus.selector)); vm.expectCall(loanContract, abi.encodeWithSelector(PWNLoan.getLOANDebt.selector), 0); - assertEq(crowdsource.totalAssets(), 120 ether); + assertEq(crowdsource.totalAssets(), 120 ether + 10 ether); } function test_shouldReturnOwnedBalance_whenEnding() external { _mockStage(PWNCrowdsourceLenderVault.Stage.ENDING); _mockCreditBalance(address(crowdsource), 120 ether); - _mockAaveCreditBalance(address(crowdsource), 10 ether); // should not be used + _mockAaveCreditBalance(address(crowdsource), 10 ether); // repayments deposited to Aave _mockLoanRepaymentAmount(99 ether); // should not be used - assertEq(crowdsource.totalAssets(), 120 ether); + assertEq(crowdsource.totalAssets(), 120 ether + 10 ether); } } @@ -932,8 +932,10 @@ contract PWNCrowdsourceLenderVault_OnLoanCreated_Test is PWNCrowdsourceLenderVau ); _mockStage(PWNCrowdsourceLenderVault.Stage.POOLING); + _mockCreditBalance(address(crowdsource), 0); - vm.expectCall(aave, abi.encodeWithSelector(IAaveLike.withdraw.selector, loan.creditAddress, type(uint256).max, address(crowdsource))); + // Only withdraw the principal amount, keep the rest in Aave + vm.expectCall(aave, abi.encodeWithSelector(IAaveLike.withdraw.selector, loan.creditAddress, loan.principal, address(crowdsource))); vm.prank(address(loanContract)); crowdsource.onLoanCreated(1, address(crowdsource), loan.creditAddress, loan.principal, ""); @@ -953,9 +955,34 @@ contract PWNCrowdsourceLenderVault_OnLoanCreated_Test is PWNCrowdsourceLenderVau contract PWNCrowdsourceLenderVault_OnLoanRepaid_Test is PWNCrowdsourceLenderVaultTest { + function setUp() override public virtual { + super.setUp(); + + aaveReserveData.aTokenAddress = makeAddr("aToken"); + _mockAaveReserveData(aaveReserveData); + + crowdsource = new PWNCrowdsourceLenderVaultHarness( + PWNLoan(loanContract), PWNInstallmentsProduct(product), IAaveLike(aave), "Crowdsource", "CRWD", terms + ); + } + + + function test_shouldRevert_whenSenderNotLoanContract() external { + vm.expectRevert(); + crowdsource.onLoanRepaid(address(crowdsource), terms.creditAddress, 100 ether, ""); + } + function test_shouldReturnCorrectValue() external { - bytes32 returnValue = crowdsource.onLoanRepaid(address(0), address(0), 0, ""); + vm.prank(address(loanContract)); + bytes32 returnValue = crowdsource.onLoanRepaid(address(crowdsource), terms.creditAddress, 100 ether, ""); assertEq(returnValue, LENDER_REPAYMENT_HOOK_RETURN_VALUE); } + function test_shouldSupplyToAave() external { + vm.expectCall(aave, abi.encodeWithSelector(IAaveLike.supply.selector, terms.creditAddress, 100 ether, address(crowdsource), 0)); + + vm.prank(address(loanContract)); + crowdsource.onLoanRepaid(address(crowdsource), terms.creditAddress, 100 ether, ""); + } + }