Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,472 changes: 0 additions & 7,472 deletions src/assets/genesis-allowlist.csv

This file was deleted.

101 changes: 58 additions & 43 deletions src/components/staking/genesis-campaign-banner.tsx
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

Copy link
Copy Markdown

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 isEligibilityLoading still controls the label and boost value. A matching boostWalletAddress will still show .../- until the API returns, and the request still fires. If this is meant to be a hard QA override, please treat forcedEligible as non-loading and consider disabling the eligibility query when it matches.

? "–"
: stakedSSV !== undefined
? calculateBoost(isEligible, stakedSSV)
: "–";
const boostIsValue = boostValue !== "–" && boostValue !== "0%";

return (
Expand Down Expand Up @@ -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]">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 h-[62px] overflow-hidden. On narrower widths, the title plus wrapped copy plus fixed-width panel can exceed 62px and get clipped. Please add a responsive taller state/layout or shorter mobile copy before launch.

{/* 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>
Expand All @@ -171,6 +185,7 @@ export const GenesisCampaignBanner: FC<ComponentPropsWithoutRef<"div">> = ({
</div>
</div>
)}
</div>
</div>
);
};
Expand Down
21 changes: 0 additions & 21 deletions src/lib/staking/genesis-allowlist.ts

This file was deleted.

36 changes: 36 additions & 0 deletions src/lib/staking/genesis-boost.test.ts
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%");
});
});
42 changes: 22 additions & 20 deletions src/lib/staking/genesis-boost.ts
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}%`;
};
66 changes: 66 additions & 0 deletions src/lib/staking/use-genesis-eligibility.ts
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}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this path appears not to be exposed by the current gateway. ssvApiBaseUrl resolves to /api/v4/{network}, so this calls /api/v4/{network}/apr/lst-snapshot/eligible/:address. The current ssv-api APR gateway only proxies /apr/current, /apr/latest, /apr/history, and /apr/snapshots/:ownerAddress, while the APR service LST controller is mounted at /api/lst-snapshot/eligible/:walletAddress without /apr. Unless the gateway route ships first, this will 404 and mark every wallet as ineligible.


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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 isEligible: data ?? false; the banner ignores isError and shows Not eligible/0%. With staleTime/gcTime: Infinity and refetch triggers disabled, a transient 404/500/network failure can stick for the session. Please surface a separate unable-to-verify state and allow retry/refetch instead of caching false eligibility.

isLoading: Boolean(normalizedAddress) && isLoading,
isError
};
}
4 changes: 2 additions & 2 deletions src/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ const Disclaimer = () => (
</a>
, and{" "}<a
className="font-semibold text-rk-accent-color"
href="https://ssv.network/legal/terms-and-conditions-for-cssv-genesis-boost-campaign/"
href="https://ssv.network/legal/terms-and-conditions-for-cssv-syndicate-boost-campaign/"
rel="noreferrer"
target="_blank"
>
cSSV Genesis Boost Campaign Terms
cSSV Syndicate Boost Campaign Terms
</a>
</span>
);
Expand Down