Skip to content

feat: cSSV Syndicate Boost banner — API-gated eligibility - #142

Open
jordanssv wants to merge 3 commits into
stagefrom
feat/genesis-banner-api-eligibility
Open

feat: cSSV Syndicate Boost banner — API-gated eligibility#142
jordanssv wants to merge 3 commits into
stagefrom
feat/genesis-banner-api-eligibility

Conversation

@jordanssv

Copy link
Copy Markdown

Summary

Reworks the staking-page campaign banner for the cSSV Syndicate Boost program. Eligibility is now resolved from the June 5 LST snapshot via the SSV API instead of a bundled hardcoded allowlist, the boost tiers are updated, and the campaign is renamed from "Genesis" to "Syndicate".

The banner remains behind VITE_GENESIS_CAMPAIGN_ACTIVE (off in stage/prod), so this does not change live behaviour until the flag is flipped.

Changes

  • Eligibility via API (once on connect). New useGenesisEligibility hook queries {ssvApiBaseUrl}/apr/lst-snapshot/eligible/{address} exactly once per wallet (react-query, staleTime/gcTime: Infinity, no refetch triggers).
  • New boost tiers, gated on eligibility, no minimum-stake floor — ineligible or zero-stake wallets get 0%. Kept in lockstep with the backend getBoost():
    SSV staked Boost
    0 – 5,000 +20%
    5,000.01 – 12,000 +15%
    12,000.01 – 20,000 +10%
    20,000+ 0%
  • Banner UI: OG/New-Holder TYPE panel → STATUS (Eligible / Not eligible); headline copy updated; retained the boostWalletAddress localStorage QA override (now forces eligibility).
  • Rename Genesis → Syndicate across user-facing copy + links (banner title, learn-more → ssv.network/cssv/syndicate, T&C link/label).
  • Layout fix: campaign text and the STATUS/BOOST panel are now a single flex row (text flex-1 min-w-0 wraps; panel flex-none) so the longer copy can no longer overlap the panel.
  • Removed genesis-allowlist.ts and the 7,472-line allowlist CSV.
  • Tests: added genesis-boost unit tests covering tier boundaries and the eligibility gate.

Verification

  • pnpm typecheck — clean ✅
  • pnpm test — 55 passed (6 new) ✅
  • pnpm build — succeeds ✅
  • Note: pnpm lint is not runnable on this branch — @typescript-eslint isn't installed and stage has the same gap, so it's pre-existing and unrelated to these changes.

Follow-ups (not in this PR)

  • Flip VITE_GENESIS_CAMPAIGN_ACTIVE=true in the mainnet env when marketing is ready to launch.
  • Confirm with the API team that the mainnet snapshot block (25251631) is final.

🤖 Generated with Claude Code

jordanssv and others added 3 commits June 17, 2026 12:13
… allowlist

The Genesis campaign banner previously determined eligibility ("OG") from a
bundled 7.4k-address CSV allowlist and applied OG/New-Holder boost tiers with a
50-SSV floor. The campaign now keys eligibility off the June 5 LST snapshot,
exposed via the SSV API.

- Add useGenesisEligibility: queries
  {ssvApiBaseUrl}/apr/lst-snapshot/eligible/{address} once per wallet on connect
  (react-query, staleTime/gcTime Infinity, no refetch triggers).
- Rewrite calculateBoost to the new single tier set (20/15/10/0%, no floor),
  gated on eligibility; ineligible or zero-stake wallets get 0%. Kept in
  lockstep with the backend getBoost().
- Banner: replace OG/New-Holder TYPE panel with an Eligible/Not eligible STATUS
  panel, update headline copy, retain the boostWalletAddress QA override
  (now forces eligibility).
- Remove genesis-allowlist.ts and the 7,472-line allowlist CSV.
- Add genesis-boost unit tests covering tier boundaries and the eligibility gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update user-facing campaign name and links from "Genesis" to "Syndicate":
- Banner title "cSSV Genesis Boost" -> "cSSV Syndicate Boost"
- Learn-more link -> https://ssv.network/cssv/syndicate
- T&C link/label -> cSSV Syndicate Boost Campaign Terms
  (terms-and-conditions-for-cssv-syndicate-boost-campaign)
- Align internal code comments to the new name

Internal symbols (GenesisCampaignBanner, VITE_GENESIS_CAMPAIGN_ACTIVE,
genesis-* filenames) left unchanged; the staking-header calculator link
(/cssv#calculator) is unrelated to the campaign and left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The campaign text and the right-hand STATUS/BOOST panel were each absolutely
positioned independently, with the text using whitespace-nowrap and no right
boundary — so the longer Syndicate copy ran underneath the panel. Replace the
two free-floating blocks with a single flex row: the text takes the remaining
space (flex-1 min-w-0, wraps as needed) and the panel is flex-none, so they can
no longer overlap at any width. Decorative layers are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@sebastian-ssvlabs sebastian-ssvlabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — Approved ✅

Reworks the staking banner to resolve cSSV Syndicate Boost eligibility from the June 5 LST snapshot API instead of a bundled 7,472-line allowlist CSV. Banner stays behind VITE_GENESIS_CAMPAIGN_ACTIVE (off in stage/prod), so no live behavior change.

Correctness

  • calculateBoost is clean: ineligible or non-positive stake → 0%; otherwise first tier whose inclusive upper bound covers the stake. Boundary tests (5,000 / 12,000 / 20,000 and the .01 rollovers) confirm the tiers match the documented backend getBoost(). ✅
  • useGenesisEligibility normalizes the address, fires once per wallet (staleTime/gcTime: Infinity, all refetch triggers off), and degrades safely — API error/unknown chain → isEligible: false rather than throwing (getNetworkConfigByChainId falls back to DEFAULT_NETWORK). ✅
  • Layout refactor to a single flex row (flex-1 min-w-0 text + flex-none panel) correctly removes the overlap risk; typecheck/build pass.

Non-blocking nits (optional, no need to address before merge)

  • File/hook/queryKey still carry the genesis name (use-genesis-eligibility, genesis-boost, "genesis-eligibility") while user-facing copy moved to Syndicate. Harmless, but a future rename would keep things consistent.
  • fetch(url, { cache: "no-store" }) is slightly redundant alongside the Infinity react-query cache — no impact.

Risk: Low — feature-flagged off; deletion of the allowlist CSV is the bulk of the diff. Confirm the noted follow-up (mainnet snapshot block 25251631 is final) before flipping the flag.

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.

: 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.

});

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.

</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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants