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 && (
+
+ )}
+
+
+ $InfoPanel>
+
+
+
+ );
+};
+
+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
+ />
+
+
+ $LeaderboardPanel>
+ );
+};
+
+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 === 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 }) => (
+