diff --git a/src/hooks/rewards/hooks.ts b/src/hooks/rewards/hooks.ts index ad9821a43..606e9dd4f 100644 --- a/src/hooks/rewards/hooks.ts +++ b/src/hooks/rewards/hooks.ts @@ -175,6 +175,52 @@ export function useBonkPnlLeaderboard() { }; } +export type RwaMarketPnlItem = { + address: string; + pnl: number; + volume: number; + position: number; +}; + +type RwaMarketPnlResponse = { + success: boolean; + market: string | null; + week: number | null; + data: RwaMarketPnlItem[]; + pagination?: { + total: number; + totalPages: number; + page: number; + perPage: number; + }; +}; + +async function getRwaMarketPnl() { + const res = await fetch( + 'https://pp-external-api-ffb2ad95ef03.herokuapp.com/api/dydx-weekly-bonk-market-pnl?perPage=1000' + ); + const parsedRes = (await res.json()) as RwaMarketPnlResponse; + return { + data: parsedRes.data, + market: parsedRes.market, + week: parsedRes.week, + }; +} + +export function useRwaMarketPnl() { + const { data, isLoading } = useQuery({ + queryKey: ['rwa-market-pnl'], + queryFn: wrapAndLogError(() => getRwaMarketPnl(), 'RwaMarketPnl/fetch', true), + }); + + return { + isLoading, + data: data?.data, + market: data?.market ?? null, + week: data?.week ?? null, + }; +} + export type LiquidationLeaderboardItem = { address: string; total_liquidation_losses: string; diff --git a/src/hooks/rewards/util.ts b/src/hooks/rewards/util.ts index 67eabd902..37297b7c3 100644 --- a/src/hooks/rewards/util.ts +++ b/src/hooks/rewards/util.ts @@ -140,6 +140,47 @@ export const LIQUIDATION_REBATES_DETAILS = { rebateAmountUsd: 1_000_000, }; +export const RWA_COMPETITION_WEEKS = [ + { week: 1, name: 'Gold Rush', startDate: '2026-04-06T00:00:00.000Z', endDate: '2026-04-13T00:00:00.000Z' }, + { week: 2, name: 'Crude Awakening', startDate: '2026-04-13T00:00:00.000Z', endDate: '2026-04-20T00:00:00.000Z' }, + { week: 3, name: 'Silver Rush', startDate: '2026-04-20T00:00:00.000Z', endDate: '2026-04-27T00:00:00.000Z' }, +]; + +const RWA_REWARDS = [ + { positionRange: [1, 1], reward: 3000 }, + { positionRange: [2, 2], reward: 2000 }, + { positionRange: [3, 3], reward: 1000 }, + { positionRange: [4, 5], reward: 750 }, + { positionRange: [6, 10], reward: 500 }, +]; + +export const RWA_COMPETITION_DETAILS = { + rewards: RWA_REWARDS, + rewardAmount: '$10,000', + rewardAmountUsd: 10_000, + topPrizeAmount: '$3,000', + leaderboardSize: 10, + startTime: '2026-04-06T00:00:00.000Z', + endTime: '2026-04-27T00:00:00.000Z', +}; + +export function getActiveRwaWeek() { + const now = new Date(); + return RWA_COMPETITION_WEEKS.find((week) => { + const start = new Date(week.startDate); + const end = new Date(week.endDate); + return now >= start && now < end; + }); +} + +export function positionToRwaRewards(position: number | undefined) { + if (!position) return 0; + const reward = RWA_REWARDS.find( + (r) => position >= r.positionRange[0]! && position <= r.positionRange[1]! + ); + return reward?.reward ?? 0; +} + export const FEB_2026_COMPETITION_DETAILS = { rewardAmount: '$1M', rewardAmountUsd: 1_000_000, diff --git a/src/pages/token/RewardsPage.tsx b/src/pages/token/RewardsPage.tsx index e6e6200f2..bbf6e4935 100644 --- a/src/pages/token/RewardsPage.tsx +++ b/src/pages/token/RewardsPage.tsx @@ -12,7 +12,7 @@ import { EMPTY_ARR } from '@/constants/objects'; import { AppRoute } from '@/constants/routes'; import { timeUnits } from '@/constants/time'; -import { CURRENT_BONK_REWARDS_DETAILS } from '@/hooks/rewards/util'; +import { getActiveRwaWeek, RWA_COMPETITION_DETAILS } from '@/hooks/rewards/util'; import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; import { useEnableLiquidationRebates } from '@/hooks/useEnableLiquidationRebates'; @@ -30,10 +30,9 @@ import { TermsOfUseLink } from '@/components/TermsOfUseLink'; import { orEmptyObj } from '@/lib/typeUtils'; -import { BonkIncentivesPanel } from './BonkIncentivesPanel'; import { BonkPnlLeaderboardPanel } from './BonkPnlLeaderboardPanel'; -import { BonkPnlPanel } from './BonkPnlPanel'; import { CompetitionLeaderboardPanel } from './CompetitionLeaderboardPanel'; +import { RwaCompetitionPanel } from './RwaCompetitionPanel'; import { GeoblockedPanel } from './GeoblockedPanel'; import { LaunchIncentivesPanel } from './LaunchIncentivesPanel'; import { LiquidationRebatesHeader } from './LiquidationRebatesHeader'; @@ -45,15 +44,14 @@ import { SwapAndStakingPanel } from './SwapAndStakingPanel'; import { UnbondingPanels } from './UnbondingPanels'; enum Tab { + RwaCompetition = 'RwaCompetition', Leaderboard = 'Leaderboard', - BonkPnl = 'BonkPnl', Rewards = 'Rewards', LiquidationRebates = 'LiquidationRebates', Competition = 'Competition', } const RewardsPage = () => { - const { titleStringKey, endTime } = CURRENT_BONK_REWARDS_DETAILS; const stringGetter = useStringGetter(); const navigate = useNavigate(); const { complianceState } = useComplianceState(); @@ -61,7 +59,11 @@ const RewardsPage = () => { const enableLiquidationRebates = useEnableLiquidationRebates(); const { usdcDenom } = useTokenConfigs(); - const [value, setValue] = useState(Tab.Leaderboard); + const [value, setValue] = useState(Tab.RwaCompetition); + + const activeWeek = getActiveRwaWeek(); + const competitionEnd = new Date(RWA_COMPETITION_DETAILS.endTime); + const endTime = activeWeek?.endDate ?? RWA_COMPETITION_DETAILS.endTime; const { totalRewards } = orEmptyObj(BonsaiHooks.useStakingRewards().data); @@ -87,40 +89,39 @@ const RewardsPage = () => { const endMs = new Date(endTime).getTime(); const msRemaining = endMs - Date.now(); - const hasEnded = msRemaining <= 0; + const hasEnded = new Date() >= competitionEnd; const daysRemaining = Math.ceil(msRemaining / timeUnits.day); - const endingSoon = !hasEnded && daysRemaining <= 5; + const endingSoon = !hasEnded && daysRemaining <= 3; + + const weekLabel = activeWeek ? `Week ${activeWeek.week}: ${activeWeek.name}` : null; const tabs = [ { - content: ( -
- -
- ), - label: stringGetter({ key: STRING_KEYS.COMPETITION_LEADERBOARD_TITLE }), - value: Tab.Leaderboard, - }, - { - content: ( -
- - -
- ), + content: , label: (
- {stringGetter({ key: titleStringKey })} + RWA Trading Competition {hasEnded ? ( {stringGetter({ key: STRING_KEYS.ENDED })} ) : endingSoon ? ( {daysRemaining} {daysRemaining === 1 ? 'day' : 'days'} left + ) : weekLabel ? ( + {weekLabel} ) : null}
), - value: Tab.BonkPnl, + value: Tab.RwaCompetition, + }, + { + content: ( +
+ +
+ ), + label: stringGetter({ key: STRING_KEYS.COMPETITION_LEADERBOARD_TITLE }), + value: Tab.Leaderboard, }, ...(enableLiquidationRebates ? [ diff --git a/src/pages/token/RwaCompetitionPanel.tsx b/src/pages/token/RwaCompetitionPanel.tsx new file mode 100644 index 000000000..8a4558401 --- /dev/null +++ b/src/pages/token/RwaCompetitionPanel.tsx @@ -0,0 +1,336 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Duration } from 'luxon'; +import styled from 'styled-components'; +import tw from 'twin.macro'; + +import { STRING_KEYS, StringGetterFunction } from '@/constants/localization'; + +import { type RwaMarketPnlItem, useRwaMarketPnl } from '@/hooks/rewards/hooks'; +import { + RWA_COMPETITION_DETAILS, + RWA_COMPETITION_WEEKS, + getActiveRwaWeek, + positionToRwaRewards, +} from '@/hooks/rewards/util'; +import { useAccounts } from '@/hooks/useAccounts'; +import { useNow } from '@/hooks/useNow'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { TrophyIcon } from '@/icons'; + +import { Icon, IconName } from '@/components/Icon'; +import { Link } from '@/components/Link'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { Output, OutputType } from '@/components/Output'; +import { Panel } from '@/components/Panel'; +import { ColumnDef, Table } from '@/components/Table'; +import { SuccessTag, PrivateTag, TagSize } from '@/components/Tag'; + +import { truncateAddress } from '@/lib/wallet'; + +const PRIZE_TIERS = [ + { place: '1st', amount: '$3,000' }, + { place: '2nd', amount: '$2,000' }, + { place: '3rd', amount: '$1,000' }, + { place: '4th-5th', amount: '$750' }, + { place: '6th-10th', amount: '$500' }, +]; + +export const RwaCompetitionPanel = () => { + const activeWeek = getActiveRwaWeek(); + const { market, week } = useRwaMarketPnl(); + + const displayWeek = activeWeek ?? RWA_COMPETITION_WEEKS[RWA_COMPETITION_WEEKS.length - 1]!; + const competitionStart = new Date(RWA_COMPETITION_DETAILS.startTime); + const competitionEnd = new Date(RWA_COMPETITION_DETAILS.endTime); + const now = new Date(); + const isActive = now >= competitionStart && now < competitionEnd; + const hasEnded = now >= competitionEnd; + + return ( +
+ <$InfoPanel> +
+
+
+
+ + RWA Trading Competition{market ? ` — ${market}` : ''} + + {isActive ? ( + Active + ) : hasEnded ? ( + Ended + ) : ( + Upcoming + )} +
+ + + Compete for {RWA_COMPETITION_DETAILS.rewardAmount} in prizes across 3 weekly RWA + trading sprints. Top 10 traders each week win. + + +
+

Rules

+
    +
  • Trade the designated RWA market each week using Bonk
  • +
  • PnL is calculated from fully closed positions only
  • +
  • Leaderboard updates multiple times daily
  • +
+
+
+ + {!hasEnded && ( +
+ +
+ +
+
+ )} +
+
+ + + +
+ ); +}; + +const RwaLeaderboardTable = () => { + const stringGetter = useStringGetter(); + const { data: pnlItems, isLoading, market } = useRwaMarketPnl(); + const { dydxAddress } = useAccounts(); + + const getRowKey = useCallback((row: RwaMarketPnlItem) => row.position, []); + + const columns = Object.values(RwaTableColumns).map((key: RwaTableColumns) => + getRwaTableColumnDef({ key, stringGetter, dydxAddress }) + ); + + const userRow = pnlItems?.find((item) => item.address === dydxAddress); + const data = [ + ...new Set([...(userRow ? [userRow] : []), ...(pnlItems?.filter((item) => item.pnl !== 0) ?? [])]), + ]; + + return ( + <$LeaderboardPanel> +
+
+
+ Leaderboard + {market && ({market})} +
+
+
+ <$Table + label="RWA Trading Competition Leaderboard" + data={data} + tableId="rwa-market-pnl" + getRowKey={getRowKey} + columns={columns} + defaultSortDescriptor={{ + column: RwaTableColumns.Rank, + direction: 'ascending', + }} + getIsRowPinned={(row) => row.address === dydxAddress} + slotEmpty={ + isLoading ? ( + + ) : ( +
+ + No data available yet +
+ ) + } + getRowAttributes={({ address }) => ({ + style: { + backgroundColor: address === dydxAddress ? 'var(--color-accent-faded)' : undefined, + }, + })} + selectionBehavior="replace" + initialPageSize={10} + withInnerBorders + withScrollSnapColumns + withScrollSnapRows + /> +
+
+ + ); +}; + +enum RwaTableColumns { + Rank = 'Rank', + Trader = 'Trader', + PNL = 'PNL', + Prize = 'Prize', +} + +const getTraderLink = (address: string) => `https://www.mintscan.io/dydx/address/${address}`; + +const getRwaTableColumnDef = ({ + key, + stringGetter, + dydxAddress, +}: { + key: RwaTableColumns; + stringGetter: StringGetterFunction; + dydxAddress?: string; +}): ColumnDef => ({ + ...( + { + [RwaTableColumns.Rank]: { + columnKey: RwaTableColumns.Rank, + getCellValue: (row) => row.position, + label: ( +
+ {stringGetter({ key: STRING_KEYS.RANK })} +
+ ), + renderCell: ({ position, address }) => ( +
+
+
+ {position} +
+
+ {position === 1 && } + {position === 2 && } + {position === 3 && } + {address === dydxAddress && ( +
+ + {stringGetter({ key: STRING_KEYS.YOU })} + +
+ )} +
+ ), + }, + [RwaTableColumns.Trader]: { + columnKey: RwaTableColumns.Trader, + getCellValue: (row) => row.address, + label: ( +
+ {stringGetter({ key: STRING_KEYS.TRADER })} +
+ ), + renderCell: ({ address }) => ( +
+ {truncateAddress(address)} + +
+ ), + }, + [RwaTableColumns.PNL]: { + columnKey: RwaTableColumns.PNL, + getCellValue: (row) => row.pnl, + label: ( +
+ {stringGetter({ key: STRING_KEYS.PNL })} +
+ ), + renderCell: ({ pnl }) => ( + = 0 ? 'var(--color-positive)' : 'var(--color-negative)' }} + tw="text-small font-medium" + type={OutputType.Fiat} + value={pnl} + /> + ), + }, + [RwaTableColumns.Prize]: { + columnKey: RwaTableColumns.Prize, + getCellValue: (row) => row.position, + label: ( +
Prize
+ ), + renderCell: ({ position }) => ( + + ), + }, + } satisfies Record> + )[key], +}); + +const WeekCountdown = ({ endTime }: { endTime: string }) => { + const targetMs = Date.parse(endTime); + const now = useNow(); + const [msLeft, setMsLeft] = useState(Math.max(0, Math.floor(targetMs - Date.now()))); + + useEffect(() => { + if (now > targetMs) return; + setMsLeft(Math.max(0, Math.floor(targetMs - now))); + }, [now, targetMs]); + + const formatted = useMemo(() => { + return Duration.fromMillis(msLeft) + .shiftTo('days', 'hours', 'minutes', 'seconds') + .toFormat("d'd' h'h' m'm' s's'", { floor: true }); + }, [msLeft]); + + return
{formatted}
; +}; + +const $InfoPanel = tw(Panel)`bg-color-layer-3 w-full`; + +const $LeaderboardPanel = styled(Panel)` + --panel-content-paddingY: 1.5rem; + --panel-content-paddingX: 1.5rem; +`; + +const $Table = styled(Table)` + --tableCell-padding: 0.25rem; + font: var(--font-mini-book); + --stickyArea-background: transparent; + + table { + --stickyArea-background: transparent; + } + + thead, + tbody { + --stickyArea-background: transparent; + tr { + td:first-of-type, + th:first-of-type { + --tableCell-padding: 0.5rem 0.25rem 0.5rem 1rem; + } + td:last-of-type, + th:last-of-type { + --tableCell-padding: 0.5rem 1rem 0.5rem 0.25rem; + } + } + } + + tbody { + font: var(--font-small-book); + } + + tfoot { + --stickyArea-background: transparent; + --tableCell-padding: 0.5rem 1rem 0.5rem 1rem; + } + + min-width: 1px; +` as typeof Table;