feat: full client-side vault flow in the showcase build
All checks were successful
deploy / deploy (push) Successful in 46s

When no on-chain vault is reachable (the production/showcase build), route
faucet -> approve -> deposit -> withdraw/claim through a client-side vault
(lib/simVault) instead of firing an on-chain tx that has no contract to hit.
This removes the ContractFunctionExecutionError and makes the desk behave
exactly like the live app: a funded wallet, realistic confirm delays,
positions that show in the portfolio, campaign depth that grows and rewards
that accrue over time (tuned to the displayed APRs). No funds move.

Wired through AppBoard (deposit/claim/withdraw), DepositPanel and
PositionPanel, plus useCampaigns/usePositions/usePosition so TVL, LP counts
and positions all reflect the simulated deposits. On-chain path (dev/anvil,
or any future real deployment) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maxgort 2026-07-12 20:28:57 +03:00
parent 70ed11c7dd
commit b147d7a766
5 changed files with 344 additions and 17 deletions

View file

@ -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<Mode>("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,

View file

@ -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 (
<div className="space-y-4">

View file

@ -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;

View file

@ -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<number, SimPosition>;
}
/** 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<void>((r) => setTimeout(r, ms));
// ------------------------------------------------------------ reward accrual
let timer: ReturnType<typeof setInterval> | 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<number, SimPosition> = { ...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);
}

View file

@ -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<number | null>(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,