-
Notifications
You must be signed in to change notification settings - Fork 1
feat: cSSV Syndicate Boost banner — API-gated eligibility #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,47 @@ | ||
| import type { ComponentPropsWithoutRef, FC } from "react"; | ||
| import { useMemo } from "react"; | ||
| import { useConnectModal } from "@rainbow-me/rainbowkit"; | ||
| import { useAccount } from "wagmi"; | ||
| import { formatUnits, getAddress, isAddress } from "viem"; | ||
|
|
||
| import { cn, safeLocalStorage } from "@/lib/utils"; | ||
| import { useStakingData, TOKEN_DECIMALS } from "@/hooks/use-staking-data"; | ||
| import { isOGHolder } from "@/lib/staking/genesis-allowlist"; | ||
| import { useGenesisEligibility } from "@/lib/staking/use-genesis-eligibility"; | ||
| import { calculateBoost } from "@/lib/staking/genesis-boost"; | ||
|
|
||
| export const GenesisCampaignBanner: FC<ComponentPropsWithoutRef<"div">> = ({ | ||
| className, | ||
| ...props | ||
| }) => { | ||
| const { isConnected, address } = useAccount(); | ||
| const { isConnected, address, chainId } = useAccount(); | ||
| const { openConnectModal } = useConnectModal(); | ||
| const { cssvBalance } = useStakingData(); | ||
| const { isEligible: apiEligible, isLoading: isEligibilityLoading } = | ||
| useGenesisEligibility(chainId, address); | ||
|
|
||
| const stakedSSV = | ||
| cssvBalance?.value !== undefined | ||
| ? Number(formatUnits(cssvBalance.value, TOKEN_DECIMALS)) | ||
| : undefined; | ||
|
|
||
| const isOG = useMemo(() => { | ||
| if (!isConnected || !address) return false; | ||
| const override = safeLocalStorage("boostWalletAddress"); | ||
| if (override && isAddress(override) && getAddress(override) === getAddress(address)) return true; | ||
| return isOGHolder(address); | ||
| }, [isConnected, address]); | ||
| // QA override: force eligibility for a specific wallet via localStorage. | ||
| const override = safeLocalStorage("boostWalletAddress"); | ||
| const forcedEligible = | ||
| !!address && | ||
| !!override && | ||
| isAddress(override) && | ||
| getAddress(override) === getAddress(address); | ||
| const isEligible = forcedEligible || apiEligible; | ||
|
|
||
| const holderLabel = isOG ? "OG" : "New Holder"; | ||
| const boostValue = | ||
| stakedSSV !== undefined ? calculateBoost(isOG, stakedSSV) : "–"; | ||
| const eligibilityLabel = isEligibilityLoading | ||
| ? "…" | ||
| : isEligible | ||
| ? "Eligible" | ||
| : "Not eligible"; | ||
| const boostValue = isEligibilityLoading | ||
| ? "–" | ||
| : stakedSSV !== undefined | ||
| ? calculateBoost(isEligible, stakedSSV) | ||
| : "–"; | ||
| const boostIsValue = boostValue !== "–" && boostValue !== "0%"; | ||
|
|
||
| return ( | ||
|
|
@@ -111,43 +121,47 @@ export const GenesisCampaignBanner: FC<ComponentPropsWithoutRef<"div">> = ({ | |
| </div> | ||
| </div> | ||
|
|
||
| {/* Campaign text */} | ||
| <div className="absolute left-[106px] top-1/2 -translate-y-1/2 flex flex-col items-start"> | ||
| <p className="text-[14px] font-bold leading-[20px] text-white whitespace-nowrap"> | ||
| cSSV Genesis Boost | ||
| </p> | ||
| <p className="text-[10px] font-medium leading-[12px] text-white whitespace-nowrap"> | ||
| Stake 50+ SSV to unlock up to 50% boosted rewards.{" "} | ||
| <a | ||
| className="text-[#e0bbfe] underline" | ||
| href="https://ssv.network/cssv" | ||
| target="_blank" | ||
| rel="noreferrer" | ||
| > | ||
| Learn more. | ||
| </a> | ||
| </p> | ||
| </div> | ||
| {/* Content row — text shrinks/wraps to fit; the right panel stays fixed | ||
| width so the two can never overlap. */} | ||
| <div className="absolute inset-0 flex items-center gap-3 pl-[106px] pr-[8px]"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Risk: this fixes overlap by allowing text wrapping, but the parent banner still has a fixed |
||
| {/* Campaign text */} | ||
| <div className="flex min-w-0 flex-1 flex-col items-start"> | ||
| <p className="text-[14px] font-bold leading-[20px] text-white"> | ||
| cSSV Syndicate Boost | ||
| </p> | ||
| <p className="text-[10px] font-medium leading-[13px] text-white"> | ||
| Held eligible LSTs at snapshot? Stake SSV for up to 20% boosted | ||
| rewards.{" "} | ||
| <a | ||
| className="text-[#e0bbfe] underline" | ||
| href="https://ssv.network/cssv/syndicate" | ||
| target="_blank" | ||
| rel="noreferrer" | ||
| > | ||
| Learn more. | ||
| </a> | ||
| </p> | ||
| </div> | ||
|
|
||
| {/* Right-side: Connect Wallet (disconnected) OR TYPE/BOOST panel (connected) */} | ||
| {!isConnected ? ( | ||
| <button | ||
| type="button" | ||
| className="absolute right-3 top-1/2 -translate-y-1/2 h-[28px] rounded-[4px] bg-[rgba(232,246,254,0.2)] px-3 text-[10px] font-medium text-white whitespace-nowrap transition-colors hover:bg-[rgba(232,246,254,0.3)]" | ||
| onClick={openConnectModal} | ||
| > | ||
| Connect Wallet | ||
| </button> | ||
| ) : ( | ||
| <div className="absolute right-[8px] top-1/2 -translate-y-1/2 flex items-center gap-[16px] rounded-[6px] bg-[rgba(232,246,254,0.2)] pl-[16px] pr-[8px] pb-[8px] pt-[8px]"> | ||
| {/* TYPE */} | ||
| {/* Right-side: Connect Wallet (disconnected) OR STATUS/BOOST panel (connected) */} | ||
| {!isConnected ? ( | ||
| <button | ||
| type="button" | ||
| className="h-[28px] flex-none rounded-[4px] bg-[rgba(232,246,254,0.2)] px-3 text-[10px] font-medium text-white whitespace-nowrap transition-colors hover:bg-[rgba(232,246,254,0.3)]" | ||
| onClick={openConnectModal} | ||
| > | ||
| Connect Wallet | ||
| </button> | ||
| ) : ( | ||
| <div className="flex flex-none items-center gap-[16px] rounded-[6px] bg-[rgba(232,246,254,0.2)] pl-[16px] pr-[8px] pb-[8px] pt-[8px]"> | ||
| {/* STATUS */} | ||
| <div className="flex flex-col gap-[2px] items-start"> | ||
| <span className="text-[8px] font-bold text-white/50 uppercase leading-normal"> | ||
| TYPE | ||
| STATUS | ||
| </span> | ||
| <div className="flex items-center gap-[2px]"> | ||
| <span className="text-[14px] font-extrabold text-white leading-normal whitespace-nowrap"> | ||
| {holderLabel} | ||
| {eligibilityLabel} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
@@ -171,6 +185,7 @@ export const GenesisCampaignBanner: FC<ComponentPropsWithoutRef<"div">> = ({ | |
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { calculateBoost } from "@/lib/staking/genesis-boost"; | ||
|
|
||
| describe("calculateBoost", () => { | ||
| it("returns 0% for ineligible wallets regardless of stake", () => { | ||
| expect(calculateBoost(false, 0)).toBe("0%"); | ||
| expect(calculateBoost(false, 4_000)).toBe("0%"); | ||
| expect(calculateBoost(false, 50_000)).toBe("0%"); | ||
| }); | ||
|
|
||
| it("returns 0% for eligible wallets with no stake", () => { | ||
| expect(calculateBoost(true, 0)).toBe("0%"); | ||
| }); | ||
|
|
||
| it("applies the 20% tier for 0–5,000 SSV", () => { | ||
| expect(calculateBoost(true, 1)).toBe("+20%"); | ||
| expect(calculateBoost(true, 2_500)).toBe("+20%"); | ||
| expect(calculateBoost(true, 5_000)).toBe("+20%"); | ||
| }); | ||
|
|
||
| it("applies the 15% tier for 5,000.01–12,000 SSV", () => { | ||
| expect(calculateBoost(true, 5_000.01)).toBe("+15%"); | ||
| expect(calculateBoost(true, 12_000)).toBe("+15%"); | ||
| }); | ||
|
|
||
| it("applies the 10% tier for 12,000.01–20,000 SSV", () => { | ||
| expect(calculateBoost(true, 12_000.01)).toBe("+10%"); | ||
| expect(calculateBoost(true, 20_000)).toBe("+10%"); | ||
| }); | ||
|
|
||
| it("returns 0% above 20,000 SSV", () => { | ||
| expect(calculateBoost(true, 20_000.01)).toBe("0%"); | ||
| expect(calculateBoost(true, 100_000)).toBe("0%"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,25 @@ | ||
| const OG_TIERS: [number, number][] = [ | ||
| [20_001, 0], | ||
| [12_001, 20], | ||
| [5_001, 30], | ||
| [0, 50], | ||
| // cSSV Syndicate Boost tiers, effective 2026-06-09. Boost is gated on the June 5 | ||
| // LST snapshot (see use-genesis-eligibility): ineligible wallets always get 0%. | ||
| // For eligible wallets the boost is determined by SSV staked, with no minimum | ||
| // stake floor. Tiers (inclusive upper bound) — kept in lockstep with the | ||
| // backend getBoost() in generate-boosted-snapshots.js: | ||
| // 0 – 5,000 => 20% | ||
| // 5,000.01 – 12,000 => 15% | ||
| // 12,000.01 – 20,000 => 10% | ||
| // 20,000+ => 0% | ||
| const BOOST_TIERS: [number, number][] = [ | ||
| [5_000, 20], | ||
| [12_000, 15], | ||
| [20_000, 10], | ||
| [Infinity, 0] | ||
| ]; | ||
|
|
||
| const NEW_HOLDER_TIERS: [number, number][] = [ | ||
| [20_001, 0], | ||
| [12_001, 10], | ||
| [5_001, 15], | ||
| [0, 25], | ||
| ]; | ||
|
|
||
| const MIN_STAKED_FOR_BOOST = 50; | ||
|
|
||
| export const calculateBoost = (isOG: boolean, stakedSSV: number): string => { | ||
| if (stakedSSV < MIN_STAKED_FOR_BOOST) return "–"; | ||
| const tiers = isOG ? OG_TIERS : NEW_HOLDER_TIERS; | ||
| const match = tiers.find(([threshold]) => stakedSSV >= threshold); | ||
| if (!match) return "–"; | ||
| return match[1] === 0 ? "0%" : `+${match[1]}%`; | ||
| export const calculateBoost = ( | ||
| isEligible: boolean, | ||
| stakedSSV: number | ||
| ): string => { | ||
| if (!isEligible || stakedSSV <= 0) return "0%"; | ||
| const match = BOOST_TIERS.find(([upperBound]) => stakedSSV <= upperBound); | ||
| const percent = match ? match[1] : 0; | ||
| return percent === 0 ? "0%" : `+${percent}%`; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { getAddress, isAddress } from "viem"; | ||
|
|
||
| import { getNetworkConfigByChainId } from "@/lib/config"; | ||
|
|
||
| // Shape returned by GET {ssvApiBaseUrl}/apr/lst-snapshot/eligible/{address}. | ||
| // A wallet is eligible if it held any tracked LST at the June 5 snapshot block. | ||
| type EligibilityResponse = { | ||
| walletAddress?: string; | ||
| eligible?: boolean; | ||
| snapshotBlock?: string; | ||
| tokens?: Array<{ | ||
| symbol: string; | ||
| tokenAddress: string; | ||
| balanceWei: string; | ||
| }>; | ||
| }; | ||
|
|
||
| async function fetchEligibility( | ||
| chainId: number | undefined, | ||
| address: string | ||
| ): Promise<boolean> { | ||
| const baseUrl = getNetworkConfigByChainId(chainId).ssvApiBaseUrl; | ||
| const url = `${baseUrl}/apr/lst-snapshot/eligible/${address}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: this path appears not to be exposed by the current gateway. |
||
|
|
||
| const response = await fetch(url, { cache: "no-store" }); | ||
| if (!response.ok) { | ||
| throw new Error(`Eligibility request failed: ${response.status}`); | ||
| } | ||
|
|
||
| const payload = (await response.json()) as EligibilityResponse; | ||
| return payload.eligible === true; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves cSSV Syndicate Boost eligibility for the connected wallet by querying | ||
| * the LST snapshot API exactly once per address. The result is cached for the | ||
| * lifetime of the session (staleTime/gcTime Infinity, no refetch triggers), so | ||
| * the request fires only when a wallet first connects. | ||
| */ | ||
| export function useGenesisEligibility( | ||
| chainId: number | undefined, | ||
| address: string | undefined | ||
| ) { | ||
| const normalizedAddress = | ||
| address && isAddress(address) ? getAddress(address) : undefined; | ||
|
|
||
| const { data, isLoading, isError } = useQuery({ | ||
| queryKey: ["genesis-eligibility", chainId, normalizedAddress], | ||
| queryFn: () => fetchEligibility(chainId, normalizedAddress as string), | ||
| enabled: Boolean(normalizedAddress), | ||
| staleTime: Infinity, | ||
| gcTime: Infinity, | ||
| refetchOnMount: false, | ||
| refetchOnWindowFocus: false, | ||
| refetchOnReconnect: false, | ||
| refetchInterval: false, | ||
| retry: 1 | ||
| }); | ||
|
|
||
| return { | ||
| isEligible: data ?? false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: failures are currently indistinguishable from an ineligible wallet. A non-OK response throws, but the hook returns |
||
| isLoading: Boolean(normalizedAddress) && isLoading, | ||
| isError | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit/QA: the override says it forces eligibility, but
isEligibilityLoadingstill controls the label and boost value. A matchingboostWalletAddresswill still show.../-until the API returns, and the request still fires. If this is meant to be a hard QA override, please treatforcedEligibleas non-loading and consider disabling the eligibility query when it matches.