diff --git a/src/components/creator/RoyaltyEarningsSection.tsx b/src/components/creator/RoyaltyEarningsSection.tsx
new file mode 100644
index 00000000..ee3533a1
--- /dev/null
+++ b/src/components/creator/RoyaltyEarningsSection.tsx
@@ -0,0 +1,210 @@
+import { UserCheck, Clock, CheckCircle2, ExternalLink } from 'lucide-react';
+import { useRoyaltyEarnings } from '@/hooks/useRoyaltyEarnings';
+import { useClaimRoyaltiesMutation } from '@/hooks/useRoyaltyClaim';
+import { truncateTxHash, buildStellarExpertTxUrl } from '@/constants/stellar';
+import { formatNumber } from '@/utils/numberFormat.utils';
+import { formatRelativeTimeLabel } from '@/utils/time.utils';
+import type {
+ RoyaltyEarnings,
+ RoyaltyEarningsEvent,
+ RoyaltyClaim,
+} from '@/services/royaltyEarnings.service';
+
+/**
+ * Royalty earnings section for the creator key management dashboard
+ * (issue #987): totals card, per-transfer breakdown, claim button, and
+ * claimed history with transaction hash links. Values refresh on a 60s
+ * interval via useRoyaltyEarnings.
+ */
+
+const CLAIM_BUTTON_TEST_ID = 'claim-royalties-button';
+
+/** Formats an XLM amount (the royalty API's unit) for display. */
+function formatXlmLabel(amount: number): string {
+ return `${formatNumber(amount, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 4,
+ })} XLM`;
+}
+
+function EarningsTotalsCard({ earnings }: { earnings: RoyaltyEarnings }) {
+ const totals = [
+ { label: 'Total Earned', value: earnings.totalEarned, testId: 'royalty-total-earned' },
+ { label: 'Pending', value: earnings.pending, testId: 'royalty-pending' },
+ { label: 'Claimed', value: earnings.claimed, testId: 'royalty-claimed' },
+ ];
+
+ return (
+
+ {totals.map(({ label, value, testId }) => (
+
+
{label}
+
+ {formatXlmLabel(value)}
+
+
+ ))}
+
+ );
+}
+
+function TransferBreakdownList({ events }: { events: RoyaltyEarningsEvent[] }) {
+ if (events.length === 0) {
+ return (
+
+
No royalty events yet
+
+ Royalties from secondary key transfers will appear here.
+
+
+ );
+ }
+
+ return (
+
+ {events.map(event => (
+ -
+
+
+ {formatXlmLabel(event.transferAmount)}
+
+
+
+ {formatRelativeTimeLabel(new Date(event.timestamp))}
+
+
+
+
Royalty
+
+ +{formatXlmLabel(event.royaltyEarned)}
+
+
+
+ ))}
+
+ );
+}
+
+function ClaimedHistoryList({ claims }: { claims: RoyaltyClaim[] }) {
+ if (claims.length === 0) {
+ return (
+
+ No claims yet. Claimed payouts will show their transaction hash here.
+
+ );
+ }
+
+ return (
+
+ {claims.map(claim => (
+ -
+
+
+
+
+ {formatXlmLabel(claim.amount)}
+
+
+ {formatRelativeTimeLabel(new Date(claim.timestamp))}
+
+
+
+
+ {truncateTxHash(claim.txHash)}
+
+
+
+ ))}
+
+ );
+}
+
+export interface RoyaltyEarningsSectionProps {
+ creatorId: string;
+ /** Overrides the earnings hook — tests inject mock data this way. */
+ queryFn?: () => Promise;
+ className?: string;
+}
+
+export function RoyaltyEarningsSection({
+ creatorId,
+ queryFn,
+ className = '',
+}: RoyaltyEarningsSectionProps) {
+ const { data: earnings, isLoading, isError } = useRoyaltyEarnings(creatorId, {
+ queryFn,
+ });
+ const claimMutation = useClaimRoyaltiesMutation(creatorId);
+
+ return (
+
+
+
+
+
+
+
+
+ Royalty Earnings
+
+
+ Accumulated royalties from secondary key transfers
+
+
+
+
+
+
+ {isLoading ? (
+
+ Loading royalty earnings…
+
+ ) : isError || !earnings ? (
+
+ We couldn't load your royalty earnings. They refresh every 60 seconds — try
+ again shortly.
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default RoyaltyEarningsSection;
diff --git a/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx b/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx
new file mode 100644
index 00000000..0d95e0ef
--- /dev/null
+++ b/src/components/creator/__tests__/RoyaltyEarningsSection.test.tsx
@@ -0,0 +1,207 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import RoyaltyEarningsSection from '../RoyaltyEarningsSection';
+import { useClaimRoyaltiesMutation } from '@/hooks/useRoyaltyClaim';
+import {
+ ROYALTY_EARNINGS_REFETCH_INTERVAL_MS,
+} from '@/hooks/useRoyaltyEarnings';
+import type { RoyaltyEarnings } from '@/services/royaltyEarnings.service';
+
+vi.mock('@/utils/toast.util', () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+const { mockMutate } = vi.hoisted(() => ({ mockMutate: vi.fn() }));
+vi.mock('@/hooks/useRoyaltyClaim', () => ({
+ useClaimRoyaltiesMutation: vi.fn(() => ({
+ mutate: mockMutate,
+ isPending: false,
+ })),
+}));
+
+const NOW = Date.UTC(2026, 8, 26, 12, 0, 0); // 2026-09-26T12:00:00Z
+
+function makeEarnings(
+ overrides: Partial = {}
+): RoyaltyEarnings {
+ return {
+ totalEarned: 45,
+ pending: 20,
+ claimed: 25,
+ events: [
+ {
+ id: 'evt-1',
+ timestamp: NOW - 60_000,
+ transferAmount: 100,
+ royaltyEarned: 2.5,
+ },
+ {
+ id: 'evt-2',
+ timestamp: NOW - 3_600_000,
+ transferAmount: 250,
+ royaltyEarned: 6.25,
+ },
+ ],
+ claims: [
+ {
+ id: 'claim-1',
+ timestamp: NOW - 86_400_000,
+ amount: 25,
+ txHash: 'abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890',
+ },
+ ],
+ ...overrides,
+ };
+}
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+}
+
+describe('RoyaltyEarningsSection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ vi.setSystemTime(NOW);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders totals aggregated from the earnings payload', async () => {
+ render(
+ makeEarnings()} />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('royalty-total-earned')).toHaveTextContent('45.00 XLM');
+ });
+ expect(screen.getByTestId('royalty-pending')).toHaveTextContent('20.00 XLM');
+ expect(screen.getByTestId('royalty-claimed')).toHaveTextContent('25.00 XLM');
+ });
+
+ it('lists every per-transfer royalty event with amount and royalty earned', async () => {
+ render(
+ makeEarnings()} />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId('royalty-event-amount')).toHaveLength(2);
+ });
+ expect(screen.getAllByTestId('royalty-event-amount')[0]).toHaveTextContent('100 XLM');
+ expect(screen.getAllByTestId('royalty-event-royalty')[0]).toHaveTextContent('+2.5 XLM');
+ expect(screen.getAllByTestId('royalty-event-royalty')[1]).toHaveTextContent('+6.25 XLM');
+ });
+
+ it('populates claimed history with transaction hash links', async () => {
+ render(
+ makeEarnings()} />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('royalty-claims-list')).toBeInTheDocument();
+ });
+ const link = screen.getByTestId('royalty-claim-tx-link');
+ expect(link).toHaveAttribute('href', expect.stringContaining('stellar.expert'));
+ expect(link).toHaveTextContent('abc123de…4567890');
+ });
+
+ it('submits a claim transaction when the claim button is clicked', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+ makeEarnings()} />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('claim-royalties-button')).toBeInTheDocument();
+ });
+ await user.click(screen.getByTestId('claim-royalties-button'));
+
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('refreshes earnings every 60 seconds', async () => {
+ const queryFn = vi.fn(async () => makeEarnings());
+ render(, {
+ wrapper: makeWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(queryFn).toHaveBeenCalledTimes(1);
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(ROYALTY_EARNINGS_REFETCH_INTERVAL_MS + 1_000);
+ });
+
+ await waitFor(() => {
+ expect(queryFn.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+
+ it('shows an empty state when there are no royalty events or claims', async () => {
+ render(
+ makeEarnings({ events: [], claims: [] })}
+ />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('royalty-events-empty')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('royalty-claims-empty')).toBeInTheDocument();
+ });
+
+ it('shows an error state when the earnings query fails', async () => {
+ render(
+ {
+ throw new Error('boom');
+ }}
+ />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('royalty-earnings-error')).toBeInTheDocument();
+ });
+ });
+
+ it('disables the claim button while a claim is in progress', async () => {
+ vi.mocked(useClaimRoyaltiesMutation).mockReturnValueOnce({
+ mutate: mockMutate,
+ isPending: true,
+ } as ReturnType);
+
+ render(
+ makeEarnings()} />,
+ { wrapper: makeWrapper() }
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('claim-royalties-button')).toBeDisabled();
+ });
+ expect(screen.getByTestId('claim-royalties-button')).toHaveTextContent('Claiming…');
+ });
+});
diff --git a/src/hooks/useRoyaltyClaim.ts b/src/hooks/useRoyaltyClaim.ts
new file mode 100644
index 00000000..60f3b122
--- /dev/null
+++ b/src/hooks/useRoyaltyClaim.ts
@@ -0,0 +1,42 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import showToast from '@/utils/toast.util';
+import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
+
+/**
+ * Creator-facing `claim_royalties` contract call issued from the dashboard's
+ * earnings section (issue #987).
+ *
+ * The on-chain wiring is not in the client yet, so the mutation simulates
+ * signing latency and resolves — matching useCreatorContractActions'
+ * submitContractCall convention. On success the royalties query is
+ * invalidated so totals and claimed history refresh.
+ */
+
+const SIGN_LATENCY_MS = 1200;
+
+async function submitClaimRoyalties(creatorId: string) {
+ // In production this signs and submits `claim_royalties` with `creatorId`
+ // via the connected wallet.
+ void creatorId;
+ await new Promise(resolve => window.setTimeout(resolve, SIGN_LATENCY_MS));
+ return { success: true as const };
+}
+
+export function useClaimRoyaltiesMutation(creatorId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['contract', 'claim_royalties', creatorId],
+ mutationFn: () => submitClaimRoyalties(creatorId),
+ onError: error => {
+ showToast.error(getSignatureErrorMessage(error));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.creators.royalties(creatorId),
+ });
+ showToast.success('Royalties claimed');
+ },
+ });
+}
diff --git a/src/hooks/useRoyaltyEarnings.ts b/src/hooks/useRoyaltyEarnings.ts
new file mode 100644
index 00000000..a98a1878
--- /dev/null
+++ b/src/hooks/useRoyaltyEarnings.ts
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import { royaltyEarningsService } from '@/services/royaltyEarnings.service';
+import type { RoyaltyEarnings } from '@/services/royaltyEarnings.service';
+
+/**
+ * How often the earnings section refreshes in the background (issue #987:
+ * "Earnings update on 60s refresh"). Matches useNotifications' 60s cadence.
+ */
+export const ROYALTY_EARNINGS_REFETCH_INTERVAL_MS = 60_000;
+
+export interface UseRoyaltyEarningsOptions {
+ /**
+ * Override the query function — used by tests to inject mock earnings
+ * without module-level vi.mock() patching (matches useCreatorActivityFeed's
+ * convention).
+ */
+ queryFn?: () => Promise;
+}
+
+/**
+ * Fetches a creator's royalty earnings, polling every 60 seconds.
+ * Query key: ['creators', creatorId, 'royalties']
+ */
+export function useRoyaltyEarnings(
+ creatorId: string,
+ options: UseRoyaltyEarningsOptions = {}
+) {
+ const fetchEarnings =
+ options.queryFn ?? (() => royaltyEarningsService.getRoyaltyEarnings(creatorId));
+
+ return useQuery({
+ queryKey: queryKeys.creators.royalties(creatorId),
+ queryFn: fetchEarnings,
+ enabled: !!creatorId,
+ refetchInterval: ROYALTY_EARNINGS_REFETCH_INTERVAL_MS,
+ });
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 684e54ea..286b3775 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -49,6 +49,8 @@ export const queryKeys = {
trending: () => ['creators', 'discovery', 'trending'] as const,
newListings: () => ['creators', 'discovery', 'newListings'] as const,
},
+ royalties: (creatorId: string) =>
+ ['creators', creatorId, 'royalties'] as const,
},
wallet: {
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx
index ad2307cf..99e7a1e6 100644
--- a/src/pages/CreatorDashboardPage.tsx
+++ b/src/pages/CreatorDashboardPage.tsx
@@ -13,6 +13,7 @@ import BuyCooldownPanel from '@/components/common/BuyCooldownPanel';
import DeprecateKeyPanel from '@/components/common/DeprecateKeyPanel';
import VestingSchedulePanel from '@/components/common/VestingSchedulePanel';
import { AlertTriangle } from 'lucide-react';
+import RoyaltyEarningsSection from '@/components/creator/RoyaltyEarningsSection';
import {
useCancelAuctionMutation,
useConfigureAuctionMutation,
@@ -205,6 +206,8 @@ export default function CreatorDashboardPage() {
+
+
{
+ try {
+ const response = await this.api.get>(
+ `/creators/${creatorId}/royalties`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const royaltyEarningsService = new RoyaltyEarningsService();