All checks were successful
deploy / deploy (push) Successful in 44s
Contract: add MIN_DEPOSIT (500e6) + BelowMinimum error, checked against the combined credit (stable leg + valued stock leg) in _deposit, so it covers single- and dual-sided deposits. Tests updated (sub-$500 fixtures raised, fuzz bounds lifted) plus three new cases; 33/33 pass. ABI regenerated. Frontend: pre-submit validation in AppBoard and DepositPanel — button disables with a "Min. deposit $500" label and an inline error below the minimum, so users get feedback before signing. Shared MIN_DEPOSIT_USD / MIN_DEPOSIT_UNITS constants. Also refreshes the local anvil addresses in the dev deployment block (dev-only; prod uses the seed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
506 lines
17 KiB
Solidity
506 lines
17 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.26;
|
|
|
|
import {Test} from "forge-std/Test.sol";
|
|
import {HoodquidityVaults} from "../src/HoodquidityVaults.sol";
|
|
import {MockERC20} from "../src/MockERC20.sol";
|
|
import {MockV3Aggregator} from "../src/MockV3Aggregator.sol";
|
|
|
|
contract HoodquidityVaultsTest is Test {
|
|
HoodquidityVaults vaults;
|
|
MockERC20 usd;
|
|
MockERC20 tsla;
|
|
MockV3Aggregator feed;
|
|
|
|
address owner = makeAddr("owner");
|
|
address alice = makeAddr("alice");
|
|
address bob = makeAddr("bob");
|
|
|
|
uint64 start;
|
|
uint64 constant DURATION = 14 days;
|
|
uint64 constant BOOST_WINDOW = 1 days;
|
|
uint16 constant BOOST = 15_000; // 1.5x
|
|
uint128 constant BUDGET = 50_000e6;
|
|
uint128 constant TARGET = 1_000_000e6;
|
|
int256 constant TSLA_PRICE = 400e8; // $400, 8 decimals
|
|
|
|
function setUp() public {
|
|
usd = new MockERC20("Test USD", "tUSD", 6, 10_000e6);
|
|
tsla = new MockERC20("Tesla Stock Token", "TSLA", 18, 1e18);
|
|
feed = new MockV3Aggregator(8, "TSLA / USD", TSLA_PRICE);
|
|
vaults = new HoodquidityVaults(owner);
|
|
|
|
start = uint64(block.timestamp + 1 hours);
|
|
|
|
usd.mint(owner, BUDGET);
|
|
vm.startPrank(owner);
|
|
usd.approve(address(vaults), BUDGET);
|
|
vaults.createCampaign(
|
|
address(usd), address(tsla), address(feed), start, start + DURATION, BOOST_WINDOW, BOOST, TARGET, BUDGET
|
|
);
|
|
vm.stopPrank();
|
|
|
|
usd.mint(alice, 100_000e6);
|
|
usd.mint(bob, 100_000e6);
|
|
tsla.mint(alice, 100e18);
|
|
vm.startPrank(alice);
|
|
usd.approve(address(vaults), type(uint256).max);
|
|
tsla.approve(address(vaults), type(uint256).max);
|
|
vm.stopPrank();
|
|
vm.prank(bob);
|
|
usd.approve(address(vaults), type(uint256).max);
|
|
}
|
|
|
|
// ------------------------------------------------------------- creation
|
|
|
|
function test_createCampaign_escrowsBudget() public view {
|
|
assertEq(usd.balanceOf(address(vaults)), BUDGET);
|
|
assertEq(vaults.campaignCount(), 1);
|
|
}
|
|
|
|
function test_createCampaign_revertsOnBadConfig() public {
|
|
vm.startPrank(owner);
|
|
usd.mint(owner, BUDGET);
|
|
usd.approve(address(vaults), BUDGET);
|
|
vm.expectRevert(HoodquidityVaults.BadConfig.selector);
|
|
vaults.createCampaign(address(usd), address(tsla), address(feed), start, start, 0, BOOST, TARGET, BUDGET);
|
|
vm.expectRevert(HoodquidityVaults.BadConfig.selector);
|
|
vaults.createCampaign(address(usd), address(tsla), address(feed), start, start + 1, 0, 9_999, TARGET, BUDGET);
|
|
vm.expectRevert(HoodquidityVaults.BadConfig.selector);
|
|
vaults.createCampaign(address(usd), address(tsla), address(0), start, start + 1, 0, BOOST, TARGET, BUDGET);
|
|
vm.stopPrank();
|
|
}
|
|
|
|
function test_createCampaign_onlyOwner() public {
|
|
vm.prank(alice);
|
|
vm.expectRevert();
|
|
vaults.createCampaign(
|
|
address(usd), address(tsla), address(feed), start, start + 1 days, 0, BOOST, TARGET, BUDGET
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------- deposits
|
|
|
|
function test_deposit_recordsPosition() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
HoodquidityVaults.Position memory p = vaults.getPosition(0, alice);
|
|
assertEq(p.amount, 1_000e6);
|
|
// inside boost window: 1.5x weight
|
|
assertEq(p.weighted, 1_500e6);
|
|
}
|
|
|
|
function test_deposit_afterBoostWindow_noBoost() public {
|
|
vm.warp(start + BOOST_WINDOW + 1);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
assertEq(vaults.getPosition(0, alice).weighted, 1_000e6);
|
|
}
|
|
|
|
function test_deposit_afterEnd_reverts() public {
|
|
vm.warp(start + DURATION);
|
|
vm.prank(alice);
|
|
vm.expectRevert(HoodquidityVaults.CampaignNotActive.selector);
|
|
vaults.deposit(0, 1_000e6);
|
|
}
|
|
|
|
function test_deposit_belowMinimum_reverts() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vm.expectRevert(HoodquidityVaults.BelowMinimum.selector);
|
|
vaults.deposit(0, 499e6);
|
|
}
|
|
|
|
function test_deposit_atMinimum_ok() public {
|
|
vm.warp(start);
|
|
assertEq(vaults.MIN_DEPOSIT(), 500e6);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 500e6);
|
|
assertEq(vaults.getPosition(0, alice).amount, 500e6);
|
|
}
|
|
|
|
function test_depositDual_combinedCreditMeetsMinimum() public {
|
|
vm.warp(start);
|
|
// stable leg alone is under $500, but 1 TSLA ($400) tips it over
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, 1e18, 200e6);
|
|
assertEq(vaults.getPosition(0, alice).amount, 600e6);
|
|
|
|
// both legs under the floor: combined $100 + $80 still reverts
|
|
vm.prank(bob);
|
|
vm.expectRevert(HoodquidityVaults.BelowMinimum.selector);
|
|
vaults.depositDual(0, 0.2e18, 100e6);
|
|
}
|
|
|
|
// -------------------------------------------------- lp counters, genesis
|
|
|
|
function test_activeLPs_tracksEntriesAndExits() public {
|
|
vm.warp(start);
|
|
assertEq(vaults.getCampaign(0).activeLPs, 0);
|
|
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 1_000e6);
|
|
assertEq(vaults.getCampaign(0).activeLPs, 2);
|
|
|
|
// re-deposit does not double count
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 500e6);
|
|
assertEq(vaults.getCampaign(0).activeLPs, 2);
|
|
|
|
// partial exit keeps the LP active
|
|
vm.warp(start + 2 days);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 500e6);
|
|
assertEq(vaults.getCampaign(0).activeLPs, 2);
|
|
|
|
// full exit removes them
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 1_000e6);
|
|
assertEq(vaults.getCampaign(0).activeLPs, 1);
|
|
}
|
|
|
|
function test_lpIndex_isStableAcrossReentry() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
assertEq(vaults.getPosition(0, alice).lpIndex, 1);
|
|
assertEq(vaults.getPosition(0, bob).lpIndex, 2);
|
|
|
|
// alice exits fully and returns: keeps her original index
|
|
vm.warp(start + 2 days);
|
|
vm.startPrank(alice);
|
|
vaults.withdraw(0, 1_000e6);
|
|
vaults.deposit(0, 500e6);
|
|
vm.stopPrank();
|
|
assertEq(vaults.getPosition(0, alice).lpIndex, 1);
|
|
assertEq(vaults.getCampaign(0).lpCount, 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------- platform fee
|
|
|
|
function test_claim_takesPlatformFee() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + DURATION + 1);
|
|
uint256 aliceBefore = usd.balanceOf(alice);
|
|
uint256 treasuryBefore = usd.balanceOf(owner);
|
|
vm.prank(alice);
|
|
vaults.claim(0);
|
|
|
|
uint256 gross = vaults.getPosition(0, alice).claimed;
|
|
assertApproxEqAbs(gross, BUDGET, 2);
|
|
assertEq(usd.balanceOf(alice) - aliceBefore, gross - gross / 10);
|
|
assertEq(usd.balanceOf(owner) - treasuryBefore, gross / 10);
|
|
}
|
|
|
|
function test_lateWithdraw_takesPlatformFeeOnRewards() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 10_000e6);
|
|
|
|
vm.warp(start + DURATION + 1);
|
|
uint256 before = usd.balanceOf(alice);
|
|
uint256 treasuryBefore = usd.balanceOf(owner);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 10_000e6);
|
|
|
|
uint256 gross = vaults.getPosition(0, alice).claimed;
|
|
assertApproxEqAbs(gross, BUDGET, 2);
|
|
// principal in full plus rewards net of 10%
|
|
assertEq(usd.balanceOf(alice) - before, 10_000e6 + gross - gross / 10);
|
|
assertEq(usd.balanceOf(owner) - treasuryBefore, gross / 10);
|
|
}
|
|
|
|
function test_setTreasury_redirectsFee() public {
|
|
address vault2 = makeAddr("treasury2");
|
|
vm.prank(owner);
|
|
vaults.setTreasury(vault2);
|
|
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
vm.warp(start + DURATION + 1);
|
|
vm.prank(alice);
|
|
vaults.claim(0);
|
|
|
|
uint256 gross = vaults.getPosition(0, alice).claimed;
|
|
assertEq(usd.balanceOf(vault2), gross / 10);
|
|
}
|
|
|
|
// ------------------------------------------------------------ dual-sided
|
|
|
|
function test_depositDual_valuesStockLegViaFeed() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, 1e18, 1_000e6); // 1 TSLA @ $400 + $1,000
|
|
|
|
HoodquidityVaults.Position memory p = vaults.getPosition(0, alice);
|
|
assertEq(p.amount, 1_400e6);
|
|
assertEq(p.stockAmount, 1e18);
|
|
assertEq(p.stockCredit, 400e6);
|
|
// boost window: 1.5x on the combined credit
|
|
assertEq(p.weighted, 2_100e6);
|
|
assertEq(tsla.balanceOf(address(vaults)), 1e18);
|
|
}
|
|
|
|
function test_quoteStock_matchesFeedMath() public view {
|
|
assertEq(vaults.quoteStock(0, 2e18), 800e6);
|
|
}
|
|
|
|
function test_dualWithdraw_returnsBothLegs() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, 1e18, 1_000e6);
|
|
|
|
vm.warp(start + DURATION + 1);
|
|
uint256 usdBefore = usd.balanceOf(alice);
|
|
uint256 tslaBefore = tsla.balanceOf(alice);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 1_400e6);
|
|
|
|
uint256 gross = vaults.getPosition(0, alice).claimed;
|
|
// stable leg 1,000 + rewards net of platform fee
|
|
assertEq(usd.balanceOf(alice) - usdBefore, 1_000e6 + gross - gross / 10);
|
|
assertEq(tsla.balanceOf(alice) - tslaBefore, 1e18);
|
|
}
|
|
|
|
function test_dualEarlyExit_feeOnStableLegOnly() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, 1e18, 1_000e6);
|
|
|
|
vm.warp(start + 7 days);
|
|
uint256 usdBefore = usd.balanceOf(alice);
|
|
uint256 tslaBefore = tsla.balanceOf(alice);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 1_400e6);
|
|
|
|
// 0.1% only on the $1,000 stable leg; the stock leg returns whole
|
|
assertEq(usd.balanceOf(alice) - usdBefore, 1_000e6 - 1e6);
|
|
assertEq(tsla.balanceOf(alice) - tslaBefore, 1e18);
|
|
}
|
|
|
|
function test_dualPartialWithdraw_proRataLegs() public {
|
|
vm.warp(start + BOOST_WINDOW + 1);
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, 1e18, 1_000e6); // credit 1,400
|
|
|
|
vm.warp(start + 7 days);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 700e6); // exactly half
|
|
|
|
HoodquidityVaults.Position memory p = vaults.getPosition(0, alice);
|
|
assertEq(p.amount, 700e6);
|
|
assertEq(p.stockAmount, 0.5e18);
|
|
assertEq(p.stockCredit, 200e6);
|
|
}
|
|
|
|
function test_depositDual_zeroStock_reverts() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vm.expectRevert(HoodquidityVaults.ZeroAmount.selector);
|
|
vaults.depositDual(0, 0, 1_000e6);
|
|
}
|
|
|
|
// -------------------------------------------------------------- rewards
|
|
|
|
function test_soloStaker_earnsFullEmission() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + DURATION);
|
|
// sole staker from t0: gets the entire budget
|
|
assertApproxEqAbs(vaults.pendingRewards(0, alice), BUDGET, 2);
|
|
}
|
|
|
|
function test_proRata_split() public {
|
|
// both outside boost window for clean 3:1 math
|
|
vm.warp(start + BOOST_WINDOW + 1);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 3_000e6);
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + DURATION);
|
|
uint256 a = vaults.pendingRewards(0, alice);
|
|
uint256 b = vaults.pendingRewards(0, bob);
|
|
assertApproxEqRel(a, b * 3, 1e12);
|
|
}
|
|
|
|
function test_boost_tiltsRewards() public {
|
|
vm.warp(start); // alice inside boost window
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + BOOST_WINDOW + 1); // bob outside
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + DURATION);
|
|
// same principal, alice must earn strictly more
|
|
assertGt(vaults.pendingRewards(0, alice), vaults.pendingRewards(0, bob));
|
|
}
|
|
|
|
function test_claim_beforeEnd_reverts() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
vm.warp(start + 1 days);
|
|
vm.prank(alice);
|
|
vm.expectRevert(HoodquidityVaults.TooEarly.selector);
|
|
vaults.claim(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------- hybrid exit
|
|
|
|
function test_earlyExit_paysFee_forfeitsRewards() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 10_000e6);
|
|
|
|
vm.warp(start + 7 days);
|
|
uint256 before = usd.balanceOf(alice);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 10_000e6);
|
|
|
|
// principal minus 0.1% fee, no rewards
|
|
assertEq(usd.balanceOf(alice) - before, 10_000e6 - 10e6);
|
|
assertEq(vaults.pendingRewards(0, alice), 0);
|
|
}
|
|
|
|
function test_earlyExit_redistributesToRemaining() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + 7 days);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 1_000e6); // alice bails at half-time
|
|
|
|
vm.warp(start + DURATION);
|
|
// bob ends up with everything: his half, alice's forfeited half and her exit fee
|
|
uint256 bobPending = vaults.pendingRewards(0, bob);
|
|
assertApproxEqAbs(bobPending, uint256(BUDGET) + 1e6, 5);
|
|
}
|
|
|
|
function test_partialEarlyExit_keepsAccruingOnRest() public {
|
|
vm.warp(start + BOOST_WINDOW + 1);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 2_000e6);
|
|
|
|
vm.warp(start + 7 days);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 1_000e6);
|
|
|
|
uint256 pendingAfterExit = vaults.pendingRewards(0, alice);
|
|
assertGt(pendingAfterExit, 0); // kept the un-withdrawn share
|
|
|
|
vm.warp(start + DURATION);
|
|
assertGt(vaults.pendingRewards(0, alice), pendingAfterExit); // still accruing
|
|
}
|
|
|
|
// ------------------------------------------------------------ solvency
|
|
|
|
function test_solvency_afterFullLifecycle() public {
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 5_000e6);
|
|
vm.prank(bob);
|
|
vaults.deposit(0, 3_000e6);
|
|
|
|
vm.warp(start + 5 days);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 2_000e6);
|
|
|
|
vm.warp(start + DURATION + 1);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, 3_000e6);
|
|
vm.prank(bob);
|
|
vaults.withdraw(0, 3_000e6);
|
|
|
|
// sweep dust after delay: must not revert on underflow
|
|
vm.warp(start + DURATION + 31 days);
|
|
vm.prank(owner);
|
|
try vaults.sweep(0) {} catch {}
|
|
|
|
// vault never pays out more than it holds
|
|
assertLe(usd.balanceOf(address(vaults)), uint256(BUDGET) + 8_000e6);
|
|
}
|
|
|
|
function test_sweep_revertsBeforeDelay() public {
|
|
vm.warp(start + DURATION + 1);
|
|
vm.prank(owner);
|
|
vm.expectRevert(HoodquidityVaults.TooEarly.selector);
|
|
vaults.sweep(0);
|
|
}
|
|
|
|
function test_sweep_recoversUnstakedEmission() public {
|
|
// nobody stakes for the first half
|
|
vm.warp(start + 7 days);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, 1_000e6);
|
|
|
|
vm.warp(start + DURATION + 31 days);
|
|
uint256 before = usd.balanceOf(owner);
|
|
vm.prank(owner);
|
|
vaults.sweep(0);
|
|
// roughly half the budget was never emitted
|
|
assertApproxEqRel(usd.balanceOf(owner) - before, BUDGET / 2, 1e15);
|
|
|
|
// alice can still claim her emitted half afterwards (claimed is gross)
|
|
vm.prank(alice);
|
|
vaults.claim(0);
|
|
assertApproxEqRel(vaults.getPosition(0, alice).claimed, BUDGET / 2, 1e15);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- fuzz
|
|
|
|
function testFuzz_deposit_withdraw_neverLosesPrincipalAfterEnd(uint96 amount) public {
|
|
amount = uint96(bound(amount, 500e6, 90_000e6));
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, amount);
|
|
|
|
vm.warp(start + DURATION + 1);
|
|
uint256 before = usd.balanceOf(alice);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, amount);
|
|
assertGe(usd.balanceOf(alice) - before, amount);
|
|
}
|
|
|
|
function testFuzz_earlyExit_feeIsExact(uint96 amount) public {
|
|
amount = uint96(bound(amount, 500e6, 90_000e6));
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.deposit(0, amount);
|
|
|
|
vm.warp(start + 3 days);
|
|
uint256 before = usd.balanceOf(alice);
|
|
vm.prank(alice);
|
|
vaults.withdraw(0, amount);
|
|
assertEq(usd.balanceOf(alice) - before, uint256(amount) - (uint256(amount) * 10) / 10_000);
|
|
}
|
|
|
|
function testFuzz_dualDeposit_valuation(uint96 stockAmt) public {
|
|
// lower bound keeps the stock-only credit above the $500 minimum
|
|
stockAmt = uint96(bound(stockAmt, 2e18, 50e18));
|
|
vm.warp(start);
|
|
vm.prank(alice);
|
|
vaults.depositDual(0, stockAmt, 0);
|
|
|
|
uint128 expected = uint128((uint256(stockAmt) * uint256(TSLA_PRICE)) / 1e20);
|
|
assertEq(vaults.getPosition(0, alice).stockCredit, expected);
|
|
}
|
|
}
|