hoodquidity/packages/contracts/test/HoodquidityVaults.t.sol
Денис Зефиров dba2811447 Hoodquidity: liquidity campaigns for Robinhood Chain stock token markets
Full build: Vite/React acid-terminal frontend (BEARER design system,
Panchang/Sentient/Fragment Mono, neon layer, palette audit script),
HoodquidityVaults Foundry contracts (single+dual deposits via Chainlink-style
feeds, hybrid exit, 10% platform fee, boost, Genesis LP), Reown AppKit wallet
connect with Robinhood Chain testnet (46630), live data from Dexscreener and
onchain reads, partner portal, design-sync setup for claude.ai/design.

Deploy: static via Forgejo Actions platform, BUILD_DIR=apps/web/dist (.env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 08:32:28 +04:00

477 lines
16 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);
}
// -------------------------------------------------- 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, 200e6);
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, 1e6, 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, 1e6, 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 {
stockAmt = uint96(bound(stockAmt, 1e15, 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);
}
}