Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 49 additions & 20 deletions src/periphery/crowdsource/PWNCrowdsourceLenderVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -193,15 +191,15 @@ 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());
}
}

/** @inheritdoc ERC4626*/
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));
}
}

Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
46 changes: 33 additions & 13 deletions test/fork/PWNCrowdsourceLenderVault.fork.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
}


Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
}


Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion test/fork/PWNStableInterestProposal.fork.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
51 changes: 39 additions & 12 deletions test/unit/PWNCrowdsourceLenderVault.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Expand Down Expand Up @@ -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, "");
Expand All @@ -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, "");
}

}
Loading