feat: enforce a $500 minimum deposit
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>
This commit is contained in:
maxgort 2026-07-12 19:58:06 +03:00
parent d6e38d2a17
commit 70ed11c7dd
6 changed files with 119 additions and 25 deletions

View file

@ -9,7 +9,13 @@ import {
import { useAppKit } from "@reown/appkit/react";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import { mockERC20Abi } from "@/lib/abi/MockERC20";
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
import {
ACTIVE_CHAIN_ID,
getActiveDeployment,
USD_DECIMALS,
MIN_DEPOSIT_USD,
MIN_DEPOSIT_UNITS,
} from "@/lib/contracts";
import { slippageImpact } from "@/lib/math";
import { fmtPct, fmtUsd } from "@/lib/format";
import { Button } from "@/components/ui/button";
@ -125,6 +131,10 @@ export function DepositPanel({
const busy = isPending || isConfirming;
const dual = mode === "dual";
// combined credit (stable leg + valued stock leg) in stable base units
const creditUnits = dual ? amount + (stockAmount > 0n ? stockQuote : 0n) : amount;
const belowMin = creditUnits > 0n && creditUnits < MIN_DEPOSIT_UNITS;
const needsUsdFaucet = amount > 0n && balance < amount;
const needsUsdApprove = !needsUsdFaucet && amount > 0n && allowance < amount;
const needsStockFaucet = dual && stockAmount > 0n && stockBalance < stockAmount;
@ -140,6 +150,7 @@ export function DepositPanel({
const act = () => {
if (!isConnected) return openConnectModal?.();
if (belowMin) return; // guarded; the button is disabled below the minimum
if (needsUsdFaucet) {
writeContract({
address: deployment.usd,
@ -195,6 +206,8 @@ export function DepositPanel({
? dual
? `Enter ${symbol} amount`
: "Enter amount"
: belowMin
? `Min. deposit $${MIN_DEPOSIT_USD}`
: needsUsdFaucet
? "Get hUSD"
: needsStockFaucet
@ -300,10 +313,18 @@ export function DepositPanel({
</div>
)}
{isConnected && belowMin && (
<p className="num font-mono text-[11px] tracking-[0.04em] text-danger">
Minimum deposit is ${MIN_DEPOSIT_USD}
</p>
)}
<Button
className="w-full"
size="lg"
disabled={busy || (isConnected && !ready) || campaign.status === "ended"}
disabled={
busy || (isConnected && !ready) || campaign.status === "ended" || (isConnected && belowMin)
}
onClick={act}
>
{busy ? "Confirming..." : label}

View file

@ -36,6 +36,19 @@ export const hoodquidityVaultsAbi = [
],
"stateMutability": "view"
},
{
"type": "function",
"name": "MIN_DEPOSIT",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint128",
"internalType": "uint128"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "PLATFORM_FEE_BPS",
@ -865,6 +878,11 @@ export const hoodquidityVaultsAbi = [
"name": "BadPrice",
"inputs": []
},
{
"type": "error",
"name": "BelowMinimum",
"inputs": []
},
{
"type": "error",
"name": "CampaignDepositsPaused",

View file

@ -33,15 +33,15 @@ interface Deployment {
/** anvil (local dev) deployment, chainId 31337 */
const local: Deployment = {
vaults: "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
usd: "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
vaults: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
usd: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
markets: [
{
symbol: "NVDA",
name: "NVIDIA Stock Token",
campaignId: 0,
stockToken: "0x59b670e9fA9D0A427751Af201D676719a970857b",
priceFeed: "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
stockToken: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9",
priceFeed: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707",
dexToken: XSTOCK.NVDA,
risk: "Medium",
},
@ -49,8 +49,8 @@ const local: Deployment = {
symbol: "TSLA",
name: "Tesla Stock Token",
campaignId: 1,
stockToken: "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f",
priceFeed: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319",
stockToken: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853",
priceFeed: "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6",
dexToken: XSTOCK.TSLA,
risk: "Medium",
},
@ -58,8 +58,8 @@ const local: Deployment = {
symbol: "AAPL",
name: "Apple Stock Token",
campaignId: 2,
stockToken: "0x09635F643e140090A9A8Dcd712eD6285858ceBef",
priceFeed: "0xc5a5C42992dECbae36851359345FE25997F5C42d",
stockToken: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788",
priceFeed: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e",
dexToken: XSTOCK.AAPL,
risk: "Low",
},
@ -67,8 +67,8 @@ const local: Deployment = {
symbol: "SPY",
name: "SPDR S&P 500 Token",
campaignId: 3,
stockToken: "0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E",
priceFeed: "0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690",
stockToken: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82",
priceFeed: "0x9A676e781A523b5d0C0e43731313A708CB607508",
dexToken: XSTOCK.SPY,
risk: "Low",
},
@ -76,8 +76,8 @@ const local: Deployment = {
symbol: "AIB",
name: "AI Basket Token",
campaignId: 4,
stockToken: "0x9E545E3C0baAB3E08CdfD552C960A1050f373042",
priceFeed: "0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9",
stockToken: "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1",
priceFeed: "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
dexBasket: [XSTOCK.NVDA, XSTOCK.META, XSTOCK.GOOGL, XSTOCK.AMZN],
risk: "High",
},
@ -137,3 +137,8 @@ export function getDeployment(chainId: number | undefined): Deployment {
}
export const USD_DECIMALS = 6;
/** Minimum accepted deposit, mirrors HoodquidityVaults.MIN_DEPOSIT. */
export const MIN_DEPOSIT_USD = 500;
/** Same floor in stable base units (6-dec) for exact bigint comparisons. */
export const MIN_DEPOSIT_UNITS = 500_000_000n; // 500 * 10 ** USD_DECIMALS

View file

@ -9,7 +9,14 @@ import {
import { useAppKit } from "@reown/appkit/react";
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
import { mockERC20Abi } from "@/lib/abi/MockERC20";
import { ACTIVE_CHAIN_ID, HAS_LIVE_VAULT, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
import {
ACTIVE_CHAIN_ID,
HAS_LIVE_VAULT,
getActiveDeployment,
USD_DECIMALS,
MIN_DEPOSIT_USD,
MIN_DEPOSIT_UNITS,
} from "@/lib/contracts";
import { useCampaigns, usePositions, type CampaignState } from "@/hooks/useCampaigns";
import { useAllMarketData } from "@/hooks/useMarketData";
import { feeApr, rewardApr } from "@/lib/math";
@ -114,6 +121,7 @@ export default function AppBoard() {
}, [isSuccess, reset, refetchWallet, refetchPositions]);
const busy = isPending || isConfirming;
const belowMin = amount > 0n && amount < MIN_DEPOSIT_UNITS;
const needsFaucet = amount > 0n && balance < amount;
const needsApprove = !needsFaucet && amount > 0n && allowance < amount;
@ -128,6 +136,7 @@ export default function AppBoard() {
if (!selected) return;
if (!isConnected) return openConnectModal?.();
if (amount === 0n) return say("enter an amount to deposit");
if (belowMin) return say(`minimum deposit is $${MIN_DEPOSIT_USD}`);
if (needsFaucet) {
lastAction.current = "hUSD minted to wallet";
writeContract({
@ -188,11 +197,13 @@ export default function AppBoard() {
? "Connect wallet"
: amount === 0n
? "Enter amount"
: needsFaucet
? "Get hUSD"
: needsApprove
? "Approve hUSD"
: "Deposit hUSD";
: belowMin
? `Min. deposit $${MIN_DEPOSIT_USD}`
: needsFaucet
? "Get hUSD"
: needsApprove
? "Approve hUSD"
: "Deposit hUSD";
return (
<div className="relative">
@ -428,6 +439,11 @@ export default function AppBoard() {
MAX
</button>
</div>
{isConnected && belowMin && (
<p className="num font-mono text-[10px] tracking-[0.04em] text-danger">
Minimum deposit is ${MIN_DEPOSIT_USD}
</p>
)}
</div>
<div className="mt-4 space-y-2 border-t border-edge pt-4 text-[13px]">
@ -450,7 +466,7 @@ export default function AppBoard() {
<Button
className="mt-5 w-full"
size="lg"
disabled={busy || selected.status === "ended"}
disabled={busy || selected.status === "ended" || (isConnected && belowMin)}
onClick={act}
>
{busy ? "Confirming..." : primaryLabel}

View file

@ -73,6 +73,9 @@ contract HoodquidityVaults is Ownable, Pausable, ReentrancyGuard {
uint16 public constant EARLY_EXIT_FEE_BPS = 10; // 0.1%
uint16 public constant PLATFORM_FEE_BPS = 1000; // 10% of reward payouts
uint256 public constant SWEEP_DELAY = 30 days;
/// @notice Smallest accepted deposit, in stable units (6-dec). Applies to
/// the combined credit (stable leg + valued stock leg) of each call.
uint128 public constant MIN_DEPOSIT = 500e6; // $500
// -------------------------------------------------------------- storage
@ -109,6 +112,7 @@ contract HoodquidityVaults is Ownable, Pausable, ReentrancyGuard {
error CampaignNotActive();
error CampaignDepositsPaused();
error ZeroAmount();
error BelowMinimum();
error NothingToWithdraw();
error NothingToClaim();
error BadConfig();
@ -231,6 +235,7 @@ contract HoodquidityVaults is Ownable, Pausable, ReentrancyGuard {
}
uint128 credit = stableAmount + stockValue;
if (credit == 0) revert ZeroAmount();
if (credit < MIN_DEPOSIT) revert BelowMinimum();
Position storage p = _positions[id][msg.sender];

View file

@ -106,6 +106,34 @@ contract HoodquidityVaultsTest is Test {
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 {
@ -149,7 +177,7 @@ contract HoodquidityVaultsTest is Test {
vm.warp(start + 2 days);
vm.startPrank(alice);
vaults.withdraw(0, 1_000e6);
vaults.deposit(0, 200e6);
vaults.deposit(0, 500e6);
vm.stopPrank();
assertEq(vaults.getPosition(0, alice).lpIndex, 1);
assertEq(vaults.getCampaign(0).lpCount, 2);
@ -440,7 +468,7 @@ contract HoodquidityVaultsTest is Test {
// ---------------------------------------------------------------- fuzz
function testFuzz_deposit_withdraw_neverLosesPrincipalAfterEnd(uint96 amount) public {
amount = uint96(bound(amount, 1e6, 90_000e6));
amount = uint96(bound(amount, 500e6, 90_000e6));
vm.warp(start);
vm.prank(alice);
vaults.deposit(0, amount);
@ -453,7 +481,7 @@ contract HoodquidityVaultsTest is Test {
}
function testFuzz_earlyExit_feeIsExact(uint96 amount) public {
amount = uint96(bound(amount, 1e6, 90_000e6));
amount = uint96(bound(amount, 500e6, 90_000e6));
vm.warp(start);
vm.prank(alice);
vaults.deposit(0, amount);
@ -466,7 +494,8 @@ contract HoodquidityVaultsTest is Test {
}
function testFuzz_dualDeposit_valuation(uint96 stockAmt) public {
stockAmt = uint96(bound(stockAmt, 1e15, 50e18));
// 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);