diff --git a/public/assets/hyperliquid-logo.png b/public/assets/hyperliquid-logo.png deleted file mode 100644 index 670d6bad..00000000 Binary files a/public/assets/hyperliquid-logo.png and /dev/null differ diff --git a/src/app/api/hyperliquid/hype-activity.ts b/src/app/api/hyperliquid/hype-activity.ts new file mode 100644 index 00000000..79ea8180 --- /dev/null +++ b/src/app/api/hyperliquid/hype-activity.ts @@ -0,0 +1,22 @@ +const HYPERLIQUID_API = "https://api.hyperliquid.xyz/info" + +/** + * Returns true if the address has at least one fill (trade) on Hyperliquid. + */ +export async function hasActivity(address: string): Promise { + try { + const response = await fetch(HYPERLIQUID_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "userFills", + user: address, + }), + }) + const fills = await response.json() + return Array.isArray(fills) && fills.length > 0 + } catch (error) { + console.error("Failed to fetch user fills:", error) + return false + } +} diff --git a/src/app/api/hyperliquid/hype-balance.ts b/src/app/api/hyperliquid/hype-balance.ts new file mode 100644 index 00000000..9d8b4797 --- /dev/null +++ b/src/app/api/hyperliquid/hype-balance.ts @@ -0,0 +1,128 @@ +const HYPERLIQUID_API = "https://api.hyperliquid.xyz/info" +const HYPERLIQUID_EVM_RPC = "https://rpc.hyperliquid.xyz/evm" + +/** HYPE token on HyperEVM. */ +const HYPE_EVM_CONTRACT = "0x2222222222222222222222222222222222222222" +/** ERC20 balanceOf(address) selector. */ +const BALANCE_OF_SELECTOR = "0x70a08231" + +/** Mainnet spot pair index for HYPE (userFills uses "@107" for spot). */ +const HYPE_SPOT_COIN = "@107" +/** Mainnet token index for HYPE (used if ledger returns token index). */ +const HYPE_TOKEN_INDEX = 150 + +async function postInfo(payload: Record): Promise { + const response = await fetch(HYPERLIQUID_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + return response.json() +} + +function isHypeFill(coin: string | undefined): boolean { + return coin === "HYPE" || coin === HYPE_SPOT_COIN +} + +function ledgerHasHype(updates: unknown): boolean { + if (!Array.isArray(updates)) return false + return updates.some( + (item: { coin?: string; token?: number }) => + item?.coin === "HYPE" || item?.token === HYPE_TOKEN_INDEX + ) +} + +/** Fetches HYPE balance on HyperEVM via eth_call. Returns null on error. */ +async function fetchEvmHypeBalance(walletAddress: string): Promise { + try { + const paddedAddress = walletAddress.slice(2).toLowerCase().padStart(64, "0") + const data = `${BALANCE_OF_SELECTOR}${paddedAddress}` + const response = await fetch(HYPERLIQUID_EVM_RPC, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_call", + params: [{ to: HYPE_EVM_CONTRACT, data }, "latest"], + id: 1, + }), + }) + const json = (await response.json()) as { result?: string; error?: unknown } + if (json.error || typeof json.result !== "string") return null + const hex = json.result + if (hex === "0x" || hex.length < 3) return BigInt(0) + return BigInt(hex) + } catch { + return null + } +} + +/** + * Fetches HYPE balance for a wallet from Hyperliquid spot clearinghouse state. + * Returns total HYPE balance (string) or "0" if none or on error. + */ +export async function fetchHypeBalance(walletAddress: string): Promise { + try { + const data = (await postInfo({ + type: "spotClearinghouseState", + user: walletAddress, + })) as { balances?: Array<{ coin: string; total: string }> } + + const hypeData = data.balances?.find((item) => item.coin === "HYPE") + return hypeData?.total ?? "0" + } catch (error) { + console.error("Failed to fetch HYPE balance:", error) + return "0" + } +} + +function checkSpotHype(spotState: unknown): boolean { + if (!spotState || typeof spotState !== "object" || !("balances" in spotState)) return false + const balances = (spotState as { balances: Array<{ coin: string; total: string }> }).balances + if (!Array.isArray(balances)) return false + return Number(balances.find((b) => b.coin === "HYPE")?.total ?? 0) > 0 +} + +function checkFillsHype(fills: unknown): boolean { + return Array.isArray(fills) && (fills as Array<{ coin?: string }>).some((f) => isHypeFill(f.coin)) +} + +/** + * Returns true if the user has ever held HYPE (current balance, ledger updates, fills, or HyperEVM balance). + * Runs spotClearinghouseState, userNonFundingLedgerUpdates, userFills, and HyperEVM balanceOf in parallel + * and resolves with true as soon as any check returns true to reduce fetch time. + */ +export async function hasEverHeldHype(walletAddress: string): Promise { + const spotPromise = postInfo({ type: "spotClearinghouseState", user: walletAddress }) + .then(checkSpotHype) + .catch(() => false) + + const ledgerPromise = postInfo({ type: "userNonFundingLedgerUpdates", user: walletAddress }) + .then(ledgerHasHype) + .catch(() => false) + + const fillsPromise = postInfo({ type: "userFills", user: walletAddress }) + .then(checkFillsHype) + .catch(() => false) + + const evmPromise = fetchEvmHypeBalance(walletAddress) + .then((b) => b !== null && b > BigInt(0)) + .catch(() => false) + + return new Promise((resolve) => { + let settled = 0 + const total = 4 + const onSettle = (value: boolean) => { + if (value) { + resolve(true) + return + } + settled += 1 + if (settled === total) resolve(false) + } + spotPromise.then(onSettle) + ledgerPromise.then(onSettle) + fillsPromise.then(onSettle) + evmPromise.then(onSettle) + }) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 35575aef..844f1a35 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -144,7 +144,6 @@ const IndexPage = () => { openConnectModal() } else { // If already connected, check tokenId as proof of minting - // tokenId must be non-zero to prove they minted if (tokenId !== null && tokenId !== undefined && tokenId !== BigInt(0)) { router.push("/dashboard") } else { diff --git a/src/components/dashboard/EcosystemSetsCarousel.tsx b/src/components/dashboard/EcosystemSetsCarousel.tsx index ee31e2ba..b9c5d1bb 100644 --- a/src/components/dashboard/EcosystemSetsCarousel.tsx +++ b/src/components/dashboard/EcosystemSetsCarousel.tsx @@ -1,169 +1,36 @@ -import React, { useState, useEffect, useCallback, useMemo } from "react" +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react" +import { flushSync } from "react-dom" import useEmblaCarousel from "embla-carousel-react" -import { ShieldCheck, ChevronLeft, ChevronRight, Loader2, Check, X } from "lucide-react" +import { + ShieldCheck, + ChevronLeft, + ChevronRight, + Loader2, + Check, + X, + HelpCircle, + ExternalLink, + Trophy, +} from "lucide-react" import { useAccount, useReadContracts } from "wagmi" -import { erc721Abi } from "viem" - -const ASSETS_URL = process.env.NEXT_PUBLIC_R2_BASE_URL - -const AZUKI_IMG = `${ASSETS_URL}/nfts/azuki.jpg` -const DOODLES_IMG = `${ASSETS_URL}/nfts/doodles.jpg` -const MOONBIRDS_IMG = `${ASSETS_URL}/nfts/moonbirds.jpg` -const PUDGY_PENGUINS_IMG = `${ASSETS_URL}/nfts/pudgy-penguins.jpg` -const YUGA_LABS_IMG = `${ASSETS_URL}/nfts/yuga-labs.jpg` -const TEST_IMG = `/assets/fast-icon.png` -const HYPERLIQUID_IMG = `/assets/hyperliquid-logo.png` +import { erc721Abi, erc20Abi } from "viem" +import { ECOSYSTEM_SETS } from "@/components/dashboard/ecosystem-carousel/criteria" +import { hasEverHeldHype } from "@/app/api/hyperliquid/hype-balance" +import { hasActivity } from "@/app/api/hyperliquid/hype-activity" +import { useGenesisSBT } from "@/hooks/use-genesis-sbt" +import { SBTGatingModal } from "@/components/modals/SBTGatingModal" const CHAIN_ETH = 1 const CHAIN_BSC = 56 +const CHAIN_HYPERLIQUID = 999 const CHAIN_NAMES: Record = { [CHAIN_ETH]: "Ethereum", [CHAIN_BSC]: "BSC", + [CHAIN_HYPERLIQUID]: "Hyperliquid", } -type ContractEntry = { - address: `0x${string}` - chainId: number - label: string -} - -const ECOSYSTEM_SETS: { - id: string - name: string - img: string - contracts: readonly ContractEntry[] - comingSoon?: boolean -}[] = [ - // { - // id: "test", - // name: "Test", - // img: TEST_IMG, - // contracts: [ - // { address: "0xd0E132C73C9425072AAB9256d63aa14D798D063A", chainId: CHAIN_ETH, label: "Test" }, - // ], - // }, - { - id: "pudgy", - name: "Pudgy\nPenguins", - img: PUDGY_PENGUINS_IMG, - contracts: [ - { - address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", - chainId: CHAIN_ETH, - label: "Pudgy Penguins (original)", - }, - { - address: "0x524cab2ec69124574082676e6f654a18df49a048", - chainId: CHAIN_ETH, - label: "Lil Pudgys", - }, - { - address: "0x062e691c2054de82f28008a8ccc6d7a1c8ce060d", - chainId: CHAIN_ETH, - label: "Pudgy Rods", - }, - ], - }, - { - id: "moonbirds", - name: "Moonbirds", - img: MOONBIRDS_IMG, - contracts: [ - { - address: "0x23581767a106ae21c074b2276d25e5c3e136a68b", - chainId: CHAIN_ETH, - label: "Moonbirds (original)", - }, - { - address: "0x1792a96e5668ad7c167ab804a100ce42395ce54d", - chainId: CHAIN_ETH, - label: "Moonbirds Oddities", - }, - { - address: "0xc0ffee8ff7e5497c2d6f7684859709225fcc5be8", - chainId: CHAIN_ETH, - label: "Moonbirds Mythics", - }, - ], - }, - { - id: "azuki", - name: "Azuki", - img: AZUKI_IMG, - contracts: [ - { - address: "0xed5af388653567af2f388e6224dc7c4b3241c544", - chainId: CHAIN_ETH, - label: "Azuki (primary)", - }, - { - address: "0x306b1ea3ecdf94ab739f1910bbda052ed4a9f949", - chainId: CHAIN_ETH, - label: "BEANZ Official", - }, - { - address: "0xb6a37b5d14d502c3ab0ae6f3a0e058bc9517786e", - chainId: CHAIN_ETH, - label: "Azuki Elementals", - }, - ], - }, - { - id: "yuga", - name: "Yuga Labs", - img: YUGA_LABS_IMG, - contracts: [ - { address: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", chainId: CHAIN_ETH, label: "BAYC" }, - { address: "0x60e4d786628fea6478f785a6d7e704777c86a7c6", chainId: CHAIN_ETH, label: "MAYC" }, - { address: "0xba30e5f9bb24caa003e9f2f0497ad287fdf95623", chainId: CHAIN_ETH, label: "BAKC" }, - { - address: "0x34d85c9cdeb23fa97cb08333b511ac86e1c4e258", - chainId: CHAIN_ETH, - label: "Otherdeed", - }, - { - address: "0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb", - chainId: CHAIN_ETH, - label: "CryptoPunks", - }, - { - address: "0x7bd29408f11d2bfc23c34f18275bbf23bb716bc7", - chainId: CHAIN_ETH, - label: "Meebits", - }, - ], - }, - { - id: "doodles", - name: "Doodles", - img: DOODLES_IMG, - contracts: [ - { - address: "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", - chainId: CHAIN_ETH, - label: "Doodles (original)", - }, - { - address: "0x89afdbf071050a67cfdc28b2ccb4277eef598f37", - chainId: CHAIN_ETH, - label: "Space Doodles", - }, - { - address: "0x466cfcd0525189b573e794f554b8a751279213ac", - chainId: CHAIN_ETH, - label: "The Dooplicator", - }, - ], - }, - { - id: "hyperliquid", - name: "Hyperliquid", - img: HYPERLIQUID_IMG, - contracts: [], - comingSoon: true, - }, -] +const CARD_HEIGHT_PX = 240 const fetchUserActivity = async (walletAddress: string): Promise> => { const res = await fetch(`/api/user-community-activity/${walletAddress}`) @@ -188,27 +55,62 @@ const saveUserActivity = async ( export const EcosystemSetCarousel = () => { const { address: userAddress, isConnected } = useAccount() + const genesisSBT = useGenesisSBT(isConnected, userAddress ?? undefined) const [verifiedSets, setVerifiedSets] = useState>({}) const [failedSets, setFailedSets] = useState>({}) const [isInitialLoading, setIsInitialLoading] = useState(true) const [manualLoadingId, setManualLoadingId] = useState(null) + const [showSBTGatingModal, setShowSBTGatingModal] = useState(false) - // VERBOSE: Initializing arrow state. In loop mode, these will mostly stay true - // unless the content is too small to scroll. const [canScrollPrev, setCanScrollPrev] = useState(false) const [canScrollNext, setCanScrollNext] = useState(false) + const [flippedCards, setFlippedCards] = useState>({}) const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "start", skipSnaps: false, }) + const emblaApiRef = useRef(emblaApi) + emblaApiRef.current = emblaApi + + const applyVerificationResult = useCallback( + (updates: { + verified?: string | null + failed?: string | null + clearManualLoading: boolean + }) => { + const scrollIndex = emblaApiRef.current?.selectedScrollSnap() ?? null + queueMicrotask(() => { + flushSync(() => { + if (updates.verified) { + setVerifiedSets((prev) => ({ ...prev, [updates.verified!]: true })) + setFailedSets((prev) => { + const next = { ...prev } + delete next[updates.verified!] + return next + }) + } + if (updates.failed) { + setFailedSets((prev) => ({ ...prev, [updates.failed!]: true })) + } + if (updates.clearManualLoading) { + setManualLoadingId(null) + } + }) + if (scrollIndex !== null && emblaApiRef.current) { + emblaApiRef.current.scrollTo(scrollIndex, true) + } + }) + }, + [] + ) const markAsVerified = useCallback( (id: string, chainId: number | null) => { if (!userAddress) return - saveUserActivity(userAddress, id, true, chainId).catch(() => { - // Verbose: error handling + saveUserActivity(userAddress, id, true, chainId).catch((err) => { + console.error("Save activity failed:", err) }) }, [userAddress] @@ -220,25 +122,13 @@ export const EcosystemSetCarousel = () => { if (!set) return [] return set.contracts.map((c) => ({ address: c.address, - abi: erc721Abi, + abi: c.kind === "erc20" ? erc20Abi : erc721Abi, functionName: "balanceOf", args: [userAddress], chainId: c.chainId, })) }, [userAddress, manualLoadingId]) - // Log what is being checked when verification runs - useEffect(() => { - if (!manualLoadingId || !userAddress) return - const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) - if (!set) return - const chainName = (id: number) => CHAIN_NAMES[id] ?? `Chain ${id}` - console.log(`[Verify Assets] Checking "${set.name.replace(/\n/g, " ")}" for ${userAddress}:`) - set.contracts.forEach((c) => { - console.log(` → ${chainName(c.chainId)} | ${c.address} (${c.label})`) - }) - }, [manualLoadingId, userAddress]) - const { data: blockchainData } = useReadContracts({ contracts, query: { enabled: isConnected && !!userAddress && !!manualLoadingId }, @@ -256,13 +146,12 @@ export const EcosystemSetCarousel = () => { }, []) useEffect(() => { - if (!isConnected) { + if (!isConnected || !userAddress) { setVerifiedSets({}) setFailedSets({}) setManualLoadingId(null) return } - if (!userAddress) return fetchUserActivity(userAddress) .then((activities) => { const fromApi: Record = {} @@ -279,23 +168,20 @@ export const EcosystemSetCarousel = () => { useEffect(() => { if (!blockchainData || !manualLoadingId) return - const results = blockchainData as { status: string; result?: unknown }[] const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) - const chainId = set?.contracts[0]?.chainId ?? null + if (!set || set.contracts.length === 0) return + + const results = blockchainData as { status: string; result?: unknown }[] + const chainId = set.contracts[0]?.chainId ?? null const hasAssets = results.some((res) => res.status === "success" && Number(res.result) > 0) if (hasAssets) { - setVerifiedSets((prev) => ({ ...prev, [manualLoadingId]: true })) - setFailedSets((prev) => { - const next = { ...prev } - delete next[manualLoadingId] - return next - }) + applyVerificationResult({ verified: manualLoadingId, clearManualLoading: true }) localStorage.setItem(`verified_${manualLoadingId}`, "true") markAsVerified(manualLoadingId, chainId) } else { - setFailedSets((prev) => ({ ...prev, [manualLoadingId]: true })) + applyVerificationResult({ failed: manualLoadingId, clearManualLoading: true }) setTimeout(() => { setFailedSets((prev) => { const next = { ...prev } @@ -304,10 +190,60 @@ export const EcosystemSetCarousel = () => { }) }, 3000) } - setManualLoadingId(null) - }, [blockchainData, manualLoadingId, markAsVerified]) + }, [blockchainData, manualLoadingId, markAsVerified, applyVerificationResult]) + + useEffect(() => { + if (!manualLoadingId || !userAddress || !isConnected) return + const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) + if (!set || set.contracts.length > 0 || !set.customCriteria?.length) return + + const chainId = CHAIN_HYPERLIQUID + + if (set.customCriteria.includes("hype_holder")) { + hasEverHeldHype(userAddress).then((verified) => { + if (verified) { + applyVerificationResult({ verified: manualLoadingId, clearManualLoading: true }) + localStorage.setItem(`verified_${manualLoadingId}`, "true") + markAsVerified(manualLoadingId, chainId) + } else { + applyVerificationResult({ failed: manualLoadingId, clearManualLoading: true }) + setTimeout(() => { + setFailedSets((prev) => { + const next = { ...prev } + delete next[manualLoadingId!] + return next + }) + }, 3000) + } + }) + return + } + + if (set.customCriteria.includes("active_depositor_trader")) { + hasActivity(userAddress).then((active) => { + if (active) { + applyVerificationResult({ verified: manualLoadingId, clearManualLoading: true }) + localStorage.setItem(`verified_${manualLoadingId}`, "true") + markAsVerified(manualLoadingId, chainId) + } else { + applyVerificationResult({ failed: manualLoadingId, clearManualLoading: true }) + setTimeout(() => { + setFailedSets((prev) => { + const next = { ...prev } + delete next[manualLoadingId!] + return next + }) + }, 3000) + } + }) + } + }, [manualLoadingId, userAddress, isConnected, markAsVerified, applyVerificationResult]) const handleVerify = (id: string) => { + if (isConnected && !genesisSBT.hasGenesisSBT) { + setShowSBTGatingModal(true) + return + } setFailedSets((prev) => { const next = { ...prev } delete next[id] @@ -316,11 +252,13 @@ export const EcosystemSetCarousel = () => { setManualLoadingId(id) } - // If we have 5 cards and the screen only fits 3, scrolling is active. + const toggleFlipped = (id: string) => { + setFlippedCards((prev) => ({ ...prev, [id]: !prev[id] })) + } + useEffect(() => { if (!emblaApi) return const updateScrollState = () => { - // In loop mode, these return true if there's enough content to scroll. setCanScrollPrev(emblaApi.canScrollPrev()) setCanScrollNext(emblaApi.canScrollNext()) } @@ -333,8 +271,6 @@ export const EcosystemSetCarousel = () => { } }, [emblaApi]) - const fitsContainer = !canScrollPrev && !canScrollNext - return (