From c0215cdf8a65ac2daad3dcbfed9edaf99db5fff4 Mon Sep 17 00:00:00 2001 From: microHoffman Date: Wed, 28 Jan 2026 21:19:09 +0700 Subject: [PATCH 1/2] feat: supply repayment to aave if possible --- .../crowdsource/PWNCrowdsourceLenderVault.sol | 69 +++++++++++++------ .../fork/PWNCrowdsourceLenderVault.fork.t.sol | 46 +++++++++---- .../fork/PWNStableInterestProposal.fork.t.sol | 3 +- test/unit/PWNCrowdsourceLenderVault.t.sol | 51 ++++++++++---- 4 files changed, 123 insertions(+), 46 deletions(-) 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, ""); + } + } From ce7c9950bc0243ef1f806b120e966be0bb959102 Mon Sep 17 00:00:00 2001 From: microHoffman Date: Thu, 29 Jan 2026 01:04:32 +0700 Subject: [PATCH 2/2] feat: add withdraw restriction max to proportional share of lender --- .../crowdsource/PWNCrowdsourceLenderVault.sol | 23 +++- .../fork/PWNCrowdsourceLenderVault.fork.t.sol | 104 +++++++++--------- .../fork/PWNStableInterestProposal.fork.t.sol | 2 +- test/unit/PWNCrowdsourceLenderVault.t.sol | 64 ++++++++++- 4 files changed, 130 insertions(+), 63 deletions(-) diff --git a/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol b/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol index adc228e..39e0ae9 100644 --- a/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol +++ b/src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol @@ -191,7 +191,8 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR max = _convertToAssets(balanceOf(owner), Math.Rounding.Down); if (_stage == Stage.RUNNING) { - max = Math.min(max, _totalAvailableLiquidity()); + // Limit withdrawal to proportional share of available liquidity based on share ownership + max = Math.min(max, _proportionalAvailableLiquidity(owner)); } } @@ -199,7 +200,8 @@ 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(_totalAvailableLiquidity(), Math.Rounding.Down)); + // Limit redemption to proportional share of available liquidity based on share ownership + max = Math.min(max, _convertToShares(_proportionalAvailableLiquidity(owner), Math.Rounding.Down)); } } @@ -284,6 +286,23 @@ contract PWNCrowdsourceLenderVault is ERC4626, IPWNLenderCreateHook, IPWNLenderR return liquidity; } + /** + * @notice Calculates the proportional share of available liquidity for an owner based on their share ownership. + * @dev During RUNNING stage, each lender can only claim their proportional share of partial repayments. + * @param owner The address of the share owner. + * @return The proportional amount of available liquidity the owner can claim. + */ + function _proportionalAvailableLiquidity(address owner) internal view returns (uint256) { + uint256 _totalSupply = totalSupply(); + if (_totalSupply == 0) return 0; + + uint256 ownerShares = balanceOf(owner); + uint256 totalLiquidity = _totalAvailableLiquidity(); + + // Calculate proportional share: totalLiquidity * ownerShares / totalSupply + return totalLiquidity.mulDiv(ownerShares, _totalSupply, Math.Rounding.Down); + } + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { super._deposit(caller, receiver, assets, shares); if (aAsset != address(0)) { diff --git a/test/fork/PWNCrowdsourceLenderVault.fork.t.sol b/test/fork/PWNCrowdsourceLenderVault.fork.t.sol index 3f4268c..b7e11f5 100644 --- a/test/fork/PWNCrowdsourceLenderVault.fork.t.sol +++ b/test/fork/PWNCrowdsourceLenderVault.fork.t.sol @@ -322,18 +322,16 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault assertApproxEqRel(lenderVault.balanceOf(lenders[0]), initialAmount, ERR_DELTA); assertEq(IERC20(USDC).balanceOf(lenders[0]), 0); - uint256 maxWithdraw = lenderVault.maxWithdraw(lenders[0]); - assertApproxEqRel(maxWithdraw, unutilizedAmount, ERR_DELTA); + // Each lender owns 1/4 of shares, so maxWithdraw is proportional: unutilized * 1/4 + uint256 maxWithdraw0 = lenderVault.maxWithdraw(lenders[0]); + assertApproxEqRel(maxWithdraw0, unutilizedAmount / 4, ERR_DELTA); vm.prank(lenders[0]); - lenderVault.withdraw(maxWithdraw, lenders[0], lenders[0]); + lenderVault.withdraw(maxWithdraw0, lenders[0], lenders[0]); - assertApproxEqRel(IERC20(USDC).balanceOf(lenders[0]), unutilizedAmount, ERR_DELTA); - - expectedTotalAssets -= maxWithdraw; + assertApproxEqRel(IERC20(USDC).balanceOf(lenders[0]), maxWithdraw0, ERR_DELTA); + expectedTotalAssets -= maxWithdraw0; assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); - assertApproxEqRel(lenderVault.balanceOf(lenders[0]), initialAmount - unutilizedAmount, ERR_DELTA); - assertApproxEqRel(IERC20(USDC).balanceOf(lenders[0]), unutilizedAmount, ERR_DELTA); uint256 repayAmount = 30_000e6; vm.prank(borrower); @@ -341,25 +339,23 @@ 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) + // After repayment, each lender can withdraw their proportional share of available liquidity uint256 maxWithdraw1 = lenderVault.maxWithdraw(lenders[1]); - assertGe(maxWithdraw1, 20_000e6); // Should be able to withdraw 20k + assertGt(maxWithdraw1, 0); vm.prank(lenders[1]); - lenderVault.withdraw(20_000e6, lenders[1], lenders[1]); + lenderVault.withdraw(maxWithdraw1, lenders[1], lenders[1]); - expectedTotalAssets -= 20_000e6; + expectedTotalAssets -= maxWithdraw1; 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) + assertGt(maxWithdraw2, 0); vm.prank(lenders[2]); - lenderVault.withdraw(9_999e6, lenders[2], lenders[2]); // Withdraw slightly less to account for rounding + lenderVault.withdraw(maxWithdraw2, lenders[2], lenders[2]); - expectedTotalAssets -= 9_999e6; + expectedTotalAssets -= maxWithdraw2; assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); } @@ -369,18 +365,14 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault assertApproxEqRel(lenderVault.balanceOf(lenders[0]), initialAmount, ERR_DELTA); assertEq(IERC20(USDC).balanceOf(lenders[0]), 0); - uint256 maxRedeem = lenderVault.maxRedeem(lenders[0]); - assertApproxEqRel(maxRedeem, unutilizedAmount, ERR_DELTA); + // Each lender owns 1/4 of shares, so maxRedeem is proportional: shares for unutilized * 1/4 + uint256 maxRedeem0 = lenderVault.maxRedeem(lenders[0]); vm.prank(lenders[0]); - lenderVault.redeem(maxRedeem, lenders[0], lenders[0]); - - assertApproxEqRel(IERC20(USDC).balanceOf(lenders[0]), unutilizedAmount, ERR_DELTA); + lenderVault.redeem(maxRedeem0, lenders[0], lenders[0]); - expectedTotalAssets -= maxRedeem; + expectedTotalAssets -= lenderVault.convertToAssets(maxRedeem0); assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); - assertApproxEqRel(lenderVault.balanceOf(lenders[0]), initialAmount - unutilizedAmount, ERR_DELTA); - assertApproxEqRel(IERC20(USDC).balanceOf(lenders[0]), unutilizedAmount, ERR_DELTA); uint256 repayAmount = 30_000e6; vm.prank(borrower); @@ -388,18 +380,14 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); + // Each lender redeems their proportional share + uint256 maxRedeem1 = lenderVault.maxRedeem(lenders[1]); vm.prank(lenders[1]); - lenderVault.redeem(20_000e6, lenders[1], lenders[1]); - - expectedTotalAssets -= 20_000e6; - assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); + lenderVault.redeem(maxRedeem1, lenders[1], lenders[1]); + uint256 maxRedeem2 = lenderVault.maxRedeem(lenders[2]); vm.prank(lenders[2]); - lenderVault.redeem(10_000e6, lenders[2], lenders[2]); - - expectedTotalAssets -= 10_000e6; - assertApproxEqRel(lenderVault.totalAssets(), expectedTotalAssets, ERR_DELTA); - + lenderVault.redeem(maxRedeem2, lenders[2], lenders[2]); } function test_shouldRevertDeposit_whenRunningStage() external { @@ -431,9 +419,11 @@ contract PWNCrowdsourceLenderVault_Running_ForkTest is PWNCrowdsourceLenderVault __d.loan.repay(loanId, 10_000e6); principal -= 10_000e6 - (principal * 3 / 100); + // Withdraw only the proportional amount allowed for lender[0] + uint256 maxWithdraw0 = lenderVault.maxWithdraw(lenders[0]); vm.prank(lenders[0]); - originalTotalShares -= lenderVault.withdraw(20_000e6, lenders[0], lenders[0]); - totalAssets -= 20_000e6; + originalTotalShares -= lenderVault.withdraw(maxWithdraw0, lenders[0], lenders[0]); + totalAssets -= maxWithdraw0; // Claim should not affect the share value assertApproxEqRel(lenderVault.convertToAssets(1e6), beforeClaimAssets, ERR_DELTA); @@ -642,11 +632,14 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende __d.loan.repay(loanId, debt < 10_000e6 ? debt : 10_000e6); repaidAmount += debt < 10_000e6 ? debt : 10_000e6; - // every 3 month claim 10k + // every 3 month claim proportional max if (i % 3 == 0) { - lender = lenders[i / 12]; - vm.prank(lender); - lenderVault.withdraw(10_000e6, lender, lender); + address _lender = lenders[i / 12]; + uint256 maxW = lenderVault.maxWithdraw(_lender); + if (maxW > 0) { + vm.prank(_lender); + lenderVault.withdraw(maxW, _lender, _lender); + } } vm.warp(block.timestamp + 30 days); @@ -658,12 +651,12 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende uint256 totalLendersBalance; // on repayment, claim by everyone for (uint256 j; j < lenders.length; ++j) { - lender = lenders[j]; - vm.startPrank(lender); - lenderVault.redeem(lenderVault.balanceOf(lender), lender, lender); + address _lender = lenders[j]; + vm.startPrank(_lender); + lenderVault.redeem(lenderVault.balanceOf(_lender), _lender, _lender); vm.stopPrank(); - totalLendersBalance += USDC.balanceOf(lender); + totalLendersBalance += USDC.balanceOf(_lender); } // assert that all assets are claimed @@ -709,11 +702,14 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende __d.loan.repay(loanId, debt < 5_000e6 ? debt : 5_000e6); repaidAmount += debt < 5_000e6 ? debt : 5_000e6; - // every 3 month claim 10k + // every 3 month claim proportional max if (i % 3 == 0) { - lender = lenders[i / 12]; - vm.prank(lender); - lenderVault.withdraw(10_000e6, lender, lender); + address _lender = lenders[i / 12]; + uint256 maxW = lenderVault.maxWithdraw(_lender); + if (maxW > 0) { + vm.prank(_lender); + lenderVault.withdraw(maxW, _lender, _lender); + } } vm.warp(block.timestamp + 30 days); @@ -725,15 +721,15 @@ contract PWNCrowdsourceLenderVault_FullLifecycle_ForkTest is PWNCrowdsourceLende uint256 totalLendersBalance; uint256 totalLendersCollateralBalance; - // on repayment, claim by everyone + // on default/repayment, claim by everyone for (uint256 j; j < lenders.length; ++j) { - lender = lenders[j]; - vm.startPrank(lender); - lenderVault.redeem(lenderVault.balanceOf(lender), lender, lender); + address _lender = lenders[j]; + vm.startPrank(_lender); + lenderVault.redeem(lenderVault.balanceOf(_lender), _lender, _lender); vm.stopPrank(); - totalLendersBalance += USDC.balanceOf(lender); - totalLendersCollateralBalance += WETH.balanceOf(lender); + totalLendersBalance += USDC.balanceOf(_lender); + totalLendersCollateralBalance += WETH.balanceOf(_lender); } // assert that all assets are claimed diff --git a/test/fork/PWNStableInterestProposal.fork.t.sol b/test/fork/PWNStableInterestProposal.fork.t.sol index 31e5b22..72abcaa 100644 --- a/test/fork/PWNStableInterestProposal.fork.t.sol +++ b/test/fork/PWNStableInterestProposal.fork.t.sol @@ -216,7 +216,7 @@ contract PWNStableProductForkTest is DeploymentTest { deal(lender, 10000 ether); deal(borrower, 10000 ether); - deal(address(ARB), borrower, 5_277e18, false); + deal(address(ARB), borrower, 5_500e18, 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 12b6dbd..29af769 100644 --- a/test/unit/PWNCrowdsourceLenderVault.t.sol +++ b/test/unit/PWNCrowdsourceLenderVault.t.sol @@ -352,20 +352,24 @@ contract PWNCrowdsourceLenderVault_MaxWithdraw_Test is PWNCrowdsourceLenderVault function test_shouldReturnUserLiquidity_whenRunningStage_whenLessThanAvailableLiquidity() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _storeReceiptBalance(lender[0], 4 ether); - _mockCreditBalance(address(crowdsource), 300 ether); + _storeReceiptTotalSupply(10 ether); + _mockCreditBalance(address(crowdsource), 1000 ether); crowdsource.workaround_setConvertToAssetsRatio(50e4); + // user assets = 4 * 50 = 200, proportional liquidity = 1000 * 4 / 10 = 400 assertEq(crowdsource.maxWithdraw(lender[0]), 200 ether); } - function test_shouldReturnAvailableLiquidity_whenRunningStage_whenLessThanUserLiquidity() external { + function test_shouldReturnProportionalLiquidity_whenRunningStage_whenLessThanUserLiquidity() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _storeReceiptBalance(lender[0], 4 ether); + _storeReceiptTotalSupply(10 ether); _mockCreditBalance(address(crowdsource), 150 ether); _mockLoanStatus(2); crowdsource.workaround_setConvertToAssetsRatio(50e4); - assertEq(crowdsource.maxWithdraw(lender[0]), 150 ether); + // user assets = 4 * 50 = 200, proportional liquidity = 150 * 4 / 10 = 60 + assertEq(crowdsource.maxWithdraw(lender[0]), 60 ether); } function test_shouldBeZero_whenEndingStage() external { @@ -395,20 +399,26 @@ contract PWNCrowdsourceLenderVault_MaxRedeem_Test is PWNCrowdsourceLenderVaultTe function test_shouldReturnUserLiquidity_whenRunningStage_whenLessThanAvailableLiquidity() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _storeReceiptBalance(lender[0], 2 ether); - _mockCreditBalance(address(crowdsource), 300 ether); + _storeReceiptTotalSupply(5 ether); + _mockCreditBalance(address(crowdsource), 1000 ether); crowdsource.workaround_setConvertToSharesRatio(0.01e4); + // shares = 2, proportional liquidity = 1000 * 2 / 5 = 400, convertToShares(400) = 400 * 0.01 = 4 + // min(2, 4) = 2 assertEq(crowdsource.maxRedeem(lender[0]), 2 ether); } - function test_shouldReturnAvailableLiquidity_whenRunningStage_whenLessThanUserLiquidity() external { + function test_shouldReturnProportionalLiquidity_whenRunningStage_whenLessThanUserLiquidity() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _storeReceiptBalance(lender[0], 4 ether); + _storeReceiptTotalSupply(10 ether); _mockCreditBalance(address(crowdsource), 200 ether); _mockLoanStatus(2); crowdsource.workaround_setConvertToSharesRatio(0.01e4); - assertEq(crowdsource.maxRedeem(lender[0]), 2 ether); + // shares = 4, proportional liquidity = 200 * 4 / 10 = 80, convertToShares(80) = 80 * 0.01 = 0.8 ether + // min(4, 0.8) = 0.8 ether + assertEq(crowdsource.maxRedeem(lender[0]), 0.8 ether); } } @@ -634,6 +644,7 @@ contract PWNCrowdsourceLenderVault_Withdraw_Test is PWNCrowdsourceLenderVaultTes _mockCreditBalance(address(crowdsource), 1000 ether); _storeReceiptBalance(lender[0], 100 ether); + _storeReceiptTotalSupply(100 ether); crowdsource.workaround_setConvertToAssetsRatio(2e4); crowdsource.workaround_setConvertToSharesRatio(0.5e4); } @@ -683,6 +694,26 @@ contract PWNCrowdsourceLenderVault_Withdraw_Test is PWNCrowdsourceLenderVaultTes crowdsource.withdraw(100 ether, lender[0], lender[0]); } + function test_shouldRevert_whenExceedingProportionalShare_whenRunningStage() external { + _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); + _mockLoanStatus(2); + + // lender[0] has 100 shares out of 400 total (25%) + _storeReceiptBalance(lender[0], 100 ether); + _storeReceiptTotalSupply(400 ether); + _mockCreditBalance(address(crowdsource), 200 ether); + + // Proportional share = 200 * 100 / 400 = 50 ether + // User asset value = 100 * 2 = 200 ether (convertToAssetsRatio = 2e4) + // maxWithdraw = min(200, 50) = 50 + assertEq(crowdsource.maxWithdraw(lender[0]), 50 ether); + + // Try to withdraw more than proportional share + vm.expectRevert("ERC4626: withdraw more than max"); + vm.prank(lender[0]); + crowdsource.withdraw(51 ether, lender[0], lender[0]); + } + function test_shouldWithdraw() external { _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); _mockLoanStatus(2); @@ -712,11 +743,32 @@ contract PWNCrowdsourceLenderVault_Redeem_Test is PWNCrowdsourceLenderVaultTest _mockCreditBalance(address(crowdsource), 1000 ether); _mockCollateralBalance(address(crowdsource), 0); _storeReceiptBalance(lender[0], 100 ether); + _storeReceiptTotalSupply(100 ether); crowdsource.workaround_setConvertToAssetsRatio(2e4); crowdsource.workaround_setConvertToSharesRatio(0.5e4); } + function test_shouldRevert_whenExceedingProportionalShare_whenRunningStage() external { + _mockStage(PWNCrowdsourceLenderVault.Stage.RUNNING); + _mockLoanStatus(2); + + // lender[0] has 100 shares out of 400 total (25%) + _storeReceiptBalance(lender[0], 100 ether); + _storeReceiptTotalSupply(400 ether); + _mockCreditBalance(address(crowdsource), 200 ether); + + // Proportional share of liquidity = 200 * 100 / 400 = 50 ether + // convertToShares(50) = 50 * 0.5 = 25 shares + // maxRedeem = min(100, 25) = 25 + assertEq(crowdsource.maxRedeem(lender[0]), 25 ether); + + // Try to redeem more than proportional share + vm.expectRevert("ERC4626: redeem more than max"); + vm.prank(lender[0]); + crowdsource.redeem(26 ether, lender[0], lender[0]); + } + function test_shouldWithdrawFromAave_whenPoolingStage() external { aaveReserveData.aTokenAddress = makeAddr("aToken"); _mockAaveReserveData(aaveReserveData);