diff --git a/apps/web/src/components/DepositPanel.tsx b/apps/web/src/components/DepositPanel.tsx index 86b8fde..a72121e 100644 --- a/apps/web/src/components/DepositPanel.tsx +++ b/apps/web/src/components/DepositPanel.tsx @@ -11,11 +11,13 @@ import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults"; import { mockERC20Abi } from "@/lib/abi/MockERC20"; import { ACTIVE_CHAIN_ID, + HAS_LIVE_VAULT, getActiveDeployment, USD_DECIMALS, MIN_DEPOSIT_USD, MIN_DEPOSIT_UNITS, } from "@/lib/contracts"; +import { useSimVault, simFaucet, simApprove, simDeposit } from "@/lib/simVault"; import { slippageImpact } from "@/lib/math"; import { fmtPct, fmtUsd } from "@/lib/format"; import { Button } from "@/components/ui/button"; @@ -40,9 +42,11 @@ export function DepositPanel({ const { address, isConnected } = useAccount(); const { open: openConnectModal } = useAppKit(); const deployment = getActiveDeployment(); + const sim = useSimVault(); const [mode, setMode] = useState("single"); const [input, setInput] = useState(""); const [stockInput, setStockInput] = useState(""); + const [simBusy, setSimBusy] = useState(false); const stockToken = campaign.meta.stockToken; const symbol = campaign.meta.symbol; @@ -105,17 +109,26 @@ export function DepositPanel({ chainId: ACTIVE_CHAIN_ID, }, ], - query: { enabled: !!address }, + query: { enabled: HAS_LIVE_VAULT && !!address }, }); - const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n; - const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n; + const stockInputNum = Number(stockInput) || 0; + // No live vault: the funded demo wallet stands in, and the stock leg is + // valued off the live Dexscreener price instead of the on-chain feed. + const balance = HAS_LIVE_VAULT + ? ((walletData?.[0]?.result as bigint | undefined) ?? 0n) + : parseUnits((isConnected ? sim.balanceUsd : 0).toFixed(USD_DECIMALS), USD_DECIMALS); + const allowance = HAS_LIVE_VAULT + ? ((walletData?.[1]?.result as bigint | undefined) ?? 0n) + : parseUnits((isConnected ? sim.allowanceUsd : 0).toFixed(USD_DECIMALS), USD_DECIMALS); const stockBalance = (walletData?.[2]?.result as bigint | undefined) ?? 0n; const stockAllowance = (walletData?.[3]?.result as bigint | undefined) ?? 0n; const stockQuote = (walletData?.[4]?.result as bigint | undefined) ?? 0n; /** USD value of the entered stock leg (or unit price when input empty). */ - const stockValueUsd = Number(stockQuote) / 10 ** USD_DECIMALS; + const stockValueUsd = HAS_LIVE_VAULT + ? Number(stockQuote) / 10 ** USD_DECIMALS + : (pair?.priceUsd ?? 0) * (stockAmount > 0n ? stockInputNum : 1); const { writeContract, data: txHash, isPending, reset } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ @@ -128,18 +141,25 @@ export function DepositPanel({ onSettled(); } - const busy = isPending || isConfirming; + const busy = isPending || isConfirming || simBusy; 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 stockCreditUnits = + stockAmount > 0n + ? HAS_LIVE_VAULT + ? stockQuote + : parseUnits(stockValueUsd.toFixed(USD_DECIMALS), USD_DECIMALS) + : 0n; + const creditUnits = dual ? amount + stockCreditUnits : 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; + // stock leg faucet/approve only apply on-chain; sim values it directly + const needsStockFaucet = HAS_LIVE_VAULT && dual && stockAmount > 0n && stockBalance < stockAmount; const needsStockApprove = - dual && !needsStockFaucet && stockAmount > 0n && stockAllowance < stockAmount; + HAS_LIVE_VAULT && dual && !needsStockFaucet && stockAmount > 0n && stockAllowance < stockAmount; const ready = dual ? stockAmount > 0n : amount > 0n; @@ -148,9 +168,28 @@ export function DepositPanel({ ? slippageImpact(PREVIEW_TRADE, pair.liquidityUsd + campaign.tvlUsd, depositUsd) : null; + const runSim = async () => { + setSimBusy(true); + try { + if (needsUsdFaucet) { + await simFaucet(); + } else if (needsUsdApprove) { + await simApprove(Number(input) || 0); + } else { + await simDeposit(campaign.meta.campaignId, depositUsd, campaign.status === "boost"); + setInput(""); + setStockInput(""); + onSettled(); + } + } finally { + setSimBusy(false); + } + }; + const act = () => { if (!isConnected) return openConnectModal?.(); if (belowMin) return; // guarded; the button is disabled below the minimum + if (!HAS_LIVE_VAULT) return void runSim(); if (needsUsdFaucet) { writeContract({ address: deployment.usd, diff --git a/apps/web/src/components/PositionPanel.tsx b/apps/web/src/components/PositionPanel.tsx index 2f60bdb..f71fda1 100644 --- a/apps/web/src/components/PositionPanel.tsx +++ b/apps/web/src/components/PositionPanel.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import { parseUnits } from "viem"; import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults"; -import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts"; +import { ACTIVE_CHAIN_ID, HAS_LIVE_VAULT, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts"; +import { simWithdraw, simClaim } from "@/lib/simVault"; import { fmtUsd, fmtCountdown, fmtNum } from "@/lib/format"; import { campaignPoints, isGenesisLp } from "@/lib/points"; import { Button } from "@/components/ui/button"; @@ -22,6 +24,7 @@ export function PositionPanel({ onSettled: () => void; }) { const deployment = getActiveDeployment(); + const [simBusy, setSimBusy] = useState(false); const { writeContract, data: txHash, isPending, reset } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash, @@ -32,11 +35,19 @@ export function PositionPanel({ onSettled(); } - const busy = isPending || isConfirming; + const busy = isPending || isConfirming || simBusy; const ended = campaign.status === "ended"; const id = BigInt(campaign.meta.campaignId); + const campaignId = campaign.meta.campaignId; - const exit = () => + const exit = () => { + if (!HAS_LIVE_VAULT) { + setSimBusy(true); + simWithdraw(campaignId) + .then(onSettled) + .finally(() => setSimBusy(false)); + return; + } writeContract({ address: deployment.vaults, abi: hoodquidityVaultsAbi, @@ -44,8 +55,16 @@ export function PositionPanel({ args: [id, parseUnits(position.amountUsd.toString(), USD_DECIMALS)], chainId: ACTIVE_CHAIN_ID, }); + }; - const claim = () => + const claim = () => { + if (!HAS_LIVE_VAULT) { + setSimBusy(true); + simClaim(campaignId) + .then(onSettled) + .finally(() => setSimBusy(false)); + return; + } writeContract({ address: deployment.vaults, abi: hoodquidityVaultsAbi, @@ -53,6 +72,7 @@ export function PositionPanel({ args: [id], chainId: ACTIVE_CHAIN_ID, }); + }; return (
diff --git a/apps/web/src/hooks/useCampaigns.ts b/apps/web/src/hooks/useCampaigns.ts index 295f0f5..58ea4b1 100644 --- a/apps/web/src/hooks/useCampaigns.ts +++ b/apps/web/src/hooks/useCampaigns.ts @@ -8,6 +8,7 @@ import { USD_DECIMALS, type MarketMeta, } from "@/lib/contracts"; +import { useSimVault } from "@/lib/simVault"; export interface CampaignState { meta: MarketMeta; @@ -63,6 +64,7 @@ function seedCampaign(meta: MarketMeta, now: number): CampaignState { export function useCampaigns() { const deployment = getActiveDeployment(); + const sim = useSimVault(); const { data, isLoading, error } = useReadContracts({ contracts: deployment.markets.map((m) => ({ @@ -77,10 +79,19 @@ export function useCampaigns() { const now = Math.floor(Date.now() / 1000); - // Showcase build with no reachable vault: render the seeded launch set. + // Showcase build with no reachable vault: render the seeded launch set, + // folding in any simulated deposits so depth and LP counts move live. if (!HAS_LIVE_VAULT) { return { - campaigns: deployment.markets.map((m) => seedCampaign(m, now)), + campaigns: deployment.markets.map((m) => { + const c = seedCampaign(m, now); + const sp = sim.positions[m.campaignId]; + if (sp && sp.amountUsd > 0) { + c.tvlUsd += sp.amountUsd; + c.activeLPs += 1; + } + return c; + }), isLoading: false, error: null, deployment, @@ -145,6 +156,7 @@ export function useCampaigns() { export function usePositions() { const { address } = useAccount(); const deployment = getActiveDeployment(); + const sim = useSimVault(); const zero = "0x0000000000000000000000000000000000000000" as const; const { data, isLoading, refetch } = useReadContracts({ @@ -167,6 +179,23 @@ export function usePositions() { query: { enabled: HAS_LIVE_VAULT && !!address, refetchInterval: 15_000 }, }); + if (!HAS_LIVE_VAULT) { + const positions = deployment.markets.map((meta) => { + const sp = address ? sim.positions[meta.campaignId] : undefined; + return { + meta, + amountUsd: sp?.amountUsd ?? 0, + claimedUsd: sp?.claimedUsd ?? 0, + pendingUsd: sp?.pendingUsd ?? 0, + boosted: sp?.boosted ?? false, + depositedAt: sp?.depositedAt ?? 0, + lpIndex: sp && sp.amountUsd > 0 ? 1 : 0, + stockAmount: 0, + }; + }); + return { positions, isLoading: false, refetch }; + } + const positions = deployment.markets.map((meta, i) => { const raw = data?.[i * 2]?.result as | { @@ -198,6 +227,7 @@ export function usePositions() { export function usePosition(campaignId: number) { const { address } = useAccount(); const deployment = getActiveDeployment(); + const sim = useSimVault(); const { data, isLoading, refetch } = useReadContracts({ contracts: [ @@ -219,6 +249,28 @@ export function usePosition(campaignId: number) { query: { enabled: HAS_LIVE_VAULT && !!address, refetchInterval: 15_000 }, }); + if (!HAS_LIVE_VAULT) { + const sp = address ? sim.positions[campaignId] : undefined; + return { + isLoading: false, + refetch, + position: + sp && sp.amountUsd > 0 + ? { + amountUsd: sp.amountUsd, + weighted: sp.weightedUsd, + claimedUsd: sp.claimedUsd, + depositedAt: sp.depositedAt, + lpIndex: 1, + stockAmount: 0, + stockCreditUsd: 0, + pendingUsd: sp.pendingUsd, + boosted: sp.boosted, + } + : undefined, + }; + } + const raw = data?.[0]?.result as | { amount: bigint; diff --git a/apps/web/src/lib/simVault.ts b/apps/web/src/lib/simVault.ts new file mode 100644 index 0000000..f3eb26a --- /dev/null +++ b/apps/web/src/lib/simVault.ts @@ -0,0 +1,165 @@ +import { useSyncExternalStore } from "react"; + +/** + * Client-side vault used when no on-chain vault is reachable (the showcase + * build). It mirrors the shape and timing of the real faucet -> approve -> + * deposit -> claim/withdraw flow so the app behaves exactly like the live + * version: balances move, positions appear in the portfolio, campaign depth + * grows and rewards accrue over time. No funds move; this is presentation + * state only. + */ + +export interface SimPosition { + amountUsd: number; + weightedUsd: number; + claimedUsd: number; + pendingUsd: number; + depositedAt: number; // unix seconds + boosted: boolean; +} + +interface SimState { + balanceUsd: number; + allowanceUsd: number; + positions: Record; +} + +/** A funded demo wallet, so the flow starts at approve -> deposit. */ +const START_BALANCE_USD = 25_000; +/** Matches MockERC20's faucet drip, in case a deposit outruns the balance. */ +const FAUCET_USD = 10_000; +/** Block-confirmation feel for each action. */ +const CONFIRM_MS = 1_100; +/** Reward accrual tuned to the campaign APRs the UI advertises (~5,000%). */ +const DEMO_APR = 50; +const SECONDS_PER_YEAR = 31_536_000; +const TICK_MS = 2_000; +const STORAGE_KEY = "hq-vault-state-v1"; + +function initial(): SimState { + return { balanceUsd: START_BALANCE_USD, allowanceUsd: 0, positions: {} }; +} + +function load(): SimState { + try { + const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as SimState; + } catch { + /* ignore */ + } + return initial(); +} + +let state: SimState = load(); +const listeners = new Set<() => void>(); + +function persist() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + /* ignore */ + } +} + +function set(next: SimState) { + state = next; + persist(); + listeners.forEach((l) => l()); +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------ reward accrual + +let timer: ReturnType | null = null; +function ensureAccrual() { + if (timer || typeof setInterval === "undefined") return; + timer = setInterval(() => { + const ids = Object.keys(state.positions); + if (ids.length === 0) return; + let changed = false; + const positions: Record = { ...state.positions }; + for (const key of ids) { + const id = Number(key); + const p = positions[id]; + if (p.amountUsd <= 0) continue; + const rate = (DEMO_APR / SECONDS_PER_YEAR) * (p.boosted ? 1.5 : 1); + positions[id] = { ...p, pendingUsd: p.pendingUsd + p.amountUsd * rate * (TICK_MS / 1000) }; + changed = true; + } + if (changed) set({ ...state, positions }); + }, TICK_MS); +} + +// ------------------------------------------------------------------- actions + +export async function simFaucet() { + await wait(CONFIRM_MS); + set({ ...state, balanceUsd: state.balanceUsd + FAUCET_USD }); +} + +export async function simApprove(amountUsd: number) { + await wait(CONFIRM_MS); + set({ ...state, allowanceUsd: amountUsd }); +} + +export async function simDeposit(campaignId: number, amountUsd: number, boosted: boolean) { + await wait(CONFIRM_MS); + const now = Math.floor(Date.now() / 1000); + const prev = state.positions[campaignId]; + const weight = amountUsd * (boosted ? 1.5 : 1); + const pos: SimPosition = prev + ? { + ...prev, + amountUsd: prev.amountUsd + amountUsd, + weightedUsd: prev.weightedUsd + weight, + depositedAt: now, + boosted: prev.boosted || boosted, + } + : { amountUsd, weightedUsd: weight, claimedUsd: 0, pendingUsd: 0, depositedAt: now, boosted }; + set({ + ...state, + balanceUsd: Math.max(0, state.balanceUsd - amountUsd), + allowanceUsd: Math.max(0, state.allowanceUsd - amountUsd), + positions: { ...state.positions, [campaignId]: pos }, + }); +} + +export async function simWithdraw(campaignId: number) { + await wait(CONFIRM_MS); + const prev = state.positions[campaignId]; + if (!prev) return; + const positions = { ...state.positions }; + delete positions[campaignId]; + // principal returns in full; accrued (unclaimed) rewards are forfeited early + set({ ...state, balanceUsd: state.balanceUsd + prev.amountUsd, positions }); +} + +export async function simClaim(campaignId: number) { + await wait(CONFIRM_MS); + const prev = state.positions[campaignId]; + if (!prev || prev.pendingUsd <= 0) return; + set({ + ...state, + balanceUsd: state.balanceUsd + prev.pendingUsd, + positions: { + ...state.positions, + [campaignId]: { ...prev, claimedUsd: prev.claimedUsd + prev.pendingUsd, pendingUsd: 0 }, + }, + }); +} + +// --------------------------------------------------------------------- hook + +function subscribe(cb: () => void) { + listeners.add(cb); + ensureAccrual(); + return () => listeners.delete(cb); +} +function getSnapshot() { + return state; +} + +export function useSimVault(): SimState { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/web/src/screens/AppBoard.tsx b/apps/web/src/screens/AppBoard.tsx index 98f4bcb..c301034 100644 --- a/apps/web/src/screens/AppBoard.tsx +++ b/apps/web/src/screens/AppBoard.tsx @@ -19,6 +19,14 @@ import { } from "@/lib/contracts"; import { useCampaigns, usePositions, type CampaignState } from "@/hooks/useCampaigns"; import { useAllMarketData } from "@/hooks/useMarketData"; +import { + useSimVault, + simFaucet, + simApprove, + simDeposit, + simWithdraw, + simClaim, +} from "@/lib/simVault"; import { feeApr, rewardApr } from "@/lib/math"; import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -47,8 +55,10 @@ export default function AppBoard() { const { campaigns, isLoading } = useCampaigns(); const { data: pairs } = useAllMarketData(deployment.markets); const { positions, refetch: refetchPositions } = usePositions(); + const sim = useSimVault(); const [tab, setTab] = useState<"campaigns" | "portfolio">("campaigns"); + const [simBusy, setSimBusy] = useState(false); const [risk, setRisk] = useState<(typeof RISKS)[number]>("All"); const [selectedId, setSelectedId] = useState(null); const [input, setInput] = useState(""); @@ -100,8 +110,13 @@ export default function AppBoard() { ], query: { enabled: HAS_LIVE_VAULT && !!address }, }); - const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n; - const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n; + // With no live vault, the funded demo wallet drives the same state machine. + const balance = HAS_LIVE_VAULT + ? ((walletData?.[0]?.result as bigint | undefined) ?? 0n) + : parseUnits((isConnected ? sim.balanceUsd : 0).toFixed(USD_DECIMALS), USD_DECIMALS); + const allowance = HAS_LIVE_VAULT + ? ((walletData?.[1]?.result as bigint | undefined) ?? 0n) + : parseUnits((isConnected ? sim.allowanceUsd : 0).toFixed(USD_DECIMALS), USD_DECIMALS); const balanceUsd = Number(balance) / 10 ** USD_DECIMALS; const { writeContract, data: txHash, isPending, reset } = useWriteContract({ @@ -120,7 +135,7 @@ export default function AppBoard() { lastAction.current = ""; }, [isSuccess, reset, refetchWallet, refetchPositions]); - const busy = isPending || isConfirming; + const busy = isPending || isConfirming || simBusy; const belowMin = amount > 0n && amount < MIN_DEPOSIT_UNITS; const needsFaucet = amount > 0n && balance < amount; const needsApprove = !needsFaucet && amount > 0n && allowance < amount; @@ -132,11 +147,33 @@ export default function AppBoard() { : 0; const selApr = selFee + selReward; + const runSim = async () => { + if (!selected) return; + setSimBusy(true); + try { + if (needsFaucet) { + await simFaucet(); + say("hUSD minted to wallet"); + } else if (needsApprove) { + await simApprove(amountUsd); + say(`approved ${fmtUsd(amountUsd)} hUSD`); + } else { + const boosted = selected.status === "boost"; + setInput(""); + await simDeposit(selected.meta.campaignId, amountUsd, boosted); + say(`deposited ${fmtUsd(amountUsd)} -> ${selected.meta.symbol} / hUSD`); + } + } finally { + setSimBusy(false); + } + }; + const act = () => { 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 (!HAS_LIVE_VAULT) return void runSim(); if (needsFaucet) { lastAction.current = "hUSD minted to wallet"; writeContract({ @@ -168,6 +205,13 @@ export default function AppBoard() { }; const claim = (campaignId: number) => { + if (!HAS_LIVE_VAULT) { + setSimBusy(true); + simClaim(campaignId) + .then(() => say("rewards claimed -> wallet")) + .finally(() => setSimBusy(false)); + return; + } lastAction.current = "rewards claimed -> wallet"; writeContract({ address: deployment.vaults, @@ -179,6 +223,13 @@ export default function AppBoard() { }; const withdraw = (campaignId: number, posAmountUsd: number) => { + if (!HAS_LIVE_VAULT) { + setSimBusy(true); + simWithdraw(campaignId) + .then(() => say("position closed · principal returned")) + .finally(() => setSimBusy(false)); + return; + } lastAction.current = "position closed · principal returned"; writeContract({ address: deployment.vaults,