feat: enforce a $500 minimum deposit
All checks were successful
deploy / deploy (push) Successful in 44s
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:
parent
d6e38d2a17
commit
70ed11c7dd
6 changed files with 119 additions and 25 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue