diff --git a/src/components/common/ReserveBalanceChip.tsx b/src/components/common/ReserveBalanceChip.tsx
new file mode 100755
index 00000000..cdc910c2
--- /dev/null
+++ b/src/components/common/ReserveBalanceChip.tsx
@@ -0,0 +1,45 @@
+import { AlertTriangle, Info } from 'lucide-react';
+import { Tooltip } from '@/components/ui/tooltip';
+import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
+import {
+ getKeyReserveStatus,
+ RESERVE_TOOLTIP_TEXT,
+ type KeyReserve,
+} from '@/utils/keyReserve.utils';
+
+interface ReserveBalanceChipProps {
+ reserve: KeyReserve;
+}
+
+/** Reserve balance value for the key detail stats row, with a low-reserve warning (#1208). */
+export default function ReserveBalanceChip({ reserve }: ReserveBalanceChipProps) {
+ const { visible, isLow } = getKeyReserveStatus(reserve);
+ if (!visible) return null;
+
+ return (
+
+
+ {formatDisplayKeyPrice(reserve.reserveStroops)}
+
+
+
+
+
+
+ {isLow && (
+
+
+ Low reserve
+
+ )}
+
+ );
+}
diff --git a/src/components/common/__tests__/ReserveBalanceChip.test.tsx b/src/components/common/__tests__/ReserveBalanceChip.test.tsx
new file mode 100755
index 00000000..2d719e08
--- /dev/null
+++ b/src/components/common/__tests__/ReserveBalanceChip.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import ReserveBalanceChip from '@/components/common/ReserveBalanceChip';
+import { RESERVE_TOOLTIP_TEXT } from '@/utils/keyReserve.utils';
+
+describe('ReserveBalanceChip', () => {
+ it('renders the balance and the explanatory tooltip', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('reserve-balance-value')).toHaveTextContent('50');
+ expect(screen.getByRole('tooltip')).toHaveTextContent(RESERVE_TOOLTIP_TEXT);
+ expect(screen.queryByTestId('reserve-low-warning')).not.toBeInTheDocument();
+ });
+
+ it('shows the low reserve warning below 10% of market cap', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('reserve-low-warning')).toHaveTextContent(
+ 'Low reserve'
+ );
+ });
+
+ it('renders nothing for a key with zero reserve', () => {
+ const { container } = render(
+
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/marketplace/CampaignCard.tsx b/src/components/marketplace/CampaignCard.tsx
old mode 100644
new mode 100755
index 2809ec65..cbeabca2
--- a/src/components/marketplace/CampaignCard.tsx
+++ b/src/components/marketplace/CampaignCard.tsx
@@ -1,11 +1,14 @@
import { Link } from 'react-router';
-import { BadgeCheck, Clock, Gift } from 'lucide-react';
+import { BadgeCheck, CheckCircle2, Clock, Gift } from 'lucide-react';
+import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import {
+ canClaimCampaignReward,
describeCampaignCriteria,
getCampaignEligibility,
type Campaign,
+ type CampaignStatus,
} from '@/utils/campaign.utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { formatSpotlightTimeRemaining } from '@/utils/spotlightAuction.utils';
@@ -15,25 +18,54 @@ interface CampaignCardProps {
nowMs: number;
/** Hides the progress bar when no wallet is connected. */
showProgress: boolean;
+ /** Lifecycle stage; drives the countdown label and claim controls (#1204). */
+ status?: CampaignStatus;
+ /** Claims the reward for an ended campaign; omit to hide the claim button. */
+ onClaim?: (campaignId: string) => void;
+ isClaiming?: boolean;
}
-/** One seasonal campaign with criteria, reward pool, countdown and eligibility (#1226). */
+function getTimeLabel(
+ campaign: Campaign,
+ status: CampaignStatus,
+ nowMs: number
+): { label: string; value: string } {
+ if (status === 'upcoming') {
+ return {
+ label: 'Starts in',
+ value: formatSpotlightTimeRemaining(campaign.startsAt, nowMs) ?? 'Soon',
+ };
+ }
+ if (status === 'ended') return { label: 'Time left', value: 'Ended' };
+ return {
+ label: 'Time left',
+ value: formatSpotlightTimeRemaining(campaign.endsAt, nowMs) ?? 'Ended',
+ };
+}
+
+/** One seasonal campaign with criteria, reward pool, countdown and eligibility (#1226, #1204). */
export default function CampaignCard({
campaign,
nowMs,
showProgress,
+ status = 'active',
+ onClaim,
+ isClaiming = false,
}: CampaignCardProps) {
- const timeRemaining =
- formatSpotlightTimeRemaining(campaign.endsAt, nowMs) ?? 'Ended';
+ const time = getTimeLabel(campaign, status, nowMs);
const eligibility = getCampaignEligibility(
campaign.walletProgress,
campaign.criteria.target
);
const progressPercent = Math.round(eligibility.percent);
+ const isClaimed = showProgress && Boolean(campaign.claimed);
+ const showClaimButton =
+ showProgress && Boolean(onClaim) && canClaimCampaignReward(campaign, nowMs);
return (
@@ -52,14 +84,25 @@ export default function CampaignCard({
{campaign.name}
- {showProgress && eligibility.eligible && (
+ {isClaimed ? (
-
- Eligible
+
+ Claimed
+ ) : (
+ showProgress &&
+ eligibility.eligible && (
+
+
+ Eligible
+
+ )
)}
@@ -86,7 +129,7 @@ export default function CampaignCard({
- Time left
+ {time.label}
- {timeRemaining}
+ {time.value}
@@ -123,6 +166,18 @@ export default function CampaignCard({
/>
)}
+
+ {showClaimButton && (
+ onClaim?.(campaign.id)}
+ disabled={isClaiming}
+ data-testid={`campaign-claim-${campaign.id}`}
+ className="w-full"
+ >
+ {isClaiming ? 'Claiming…' : 'Claim reward'}
+
+ )}
);
}
diff --git a/src/hooks/useCampaigns.ts b/src/hooks/useCampaigns.ts
index 46cf5b20..31d5fd63 100644
--- a/src/hooks/useCampaigns.ts
+++ b/src/hooks/useCampaigns.ts
@@ -1,6 +1,11 @@
-import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
+import type { Signer } from '@/lib/signing/types';
+import { campaignContractService } from '@/services/campaignContract.service';
import { campaignsService } from '@/services/campaigns.service';
+import type { Campaign } from '@/utils/campaign.utils';
+import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
+import showToast from '@/utils/toast.util';
// Campaign progress only moves on trades, so a minute keeps it fresh without
// competing with the discovery page's own 60s refresh.
@@ -14,3 +19,49 @@ export function useActiveCampaigns(wallet: string | undefined) {
refetchInterval: CAMPAIGNS_POLL_INTERVAL_MS,
});
}
+
+/** Every seasonal campaign — upcoming, active and ended — for the campaigns page (#1204). */
+export function useCampaigns(wallet: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.campaigns.list(wallet),
+ queryFn: () => campaignsService.getCampaigns(wallet),
+ refetchInterval: CAMPAIGNS_POLL_INTERVAL_MS,
+ });
+}
+
+/** Claims an ended campaign's reward via the on-chain `claim_campaign_reward` call (#1204). */
+export function useClaimCampaignRewardMutation(
+ wallet: string,
+ signer: Signer | null
+) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['contract', 'claim_campaign_reward', wallet],
+ mutationFn: (campaignId: string) =>
+ campaignContractService.claimCampaignReward({
+ campaignId,
+ wallet,
+ signer,
+ }),
+ onError: error => {
+ showToast.error(getSignatureErrorMessage(error));
+ },
+ onSuccess: (_result, campaignId) => {
+ // Show the Claimed badge straight away; the refetch below confirms it.
+ queryClient.setQueryData(
+ queryKeys.campaigns.list(wallet),
+ campaigns =>
+ campaigns?.map(campaign =>
+ campaign.id === campaignId ? { ...campaign, claimed: true } : campaign
+ )
+ );
+ showToast.success('Campaign reward claimed');
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.campaigns.all(),
+ });
+ },
+ });
+}
diff --git a/src/hooks/useKeyReserve.ts b/src/hooks/useKeyReserve.ts
new file mode 100755
index 00000000..4631a179
--- /dev/null
+++ b/src/hooks/useKeyReserve.ts
@@ -0,0 +1,14 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import { keyReserveService } from '@/services/keyReserve.service';
+
+/** Protocol reserve backing a creator key, refetched on every page load (#1208). */
+export function useKeyReserve(keyId: string) {
+ return useQuery({
+ queryKey: queryKeys.creators.reserve(keyId),
+ queryFn: () => keyReserveService.getKeyReserve(keyId),
+ enabled: !!keyId,
+ refetchOnMount: 'always',
+ retry: false,
+ });
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 35e74288..37a92c8f 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -51,6 +51,8 @@ export const queryKeys = {
['creators', creatorId, 'tips'] as const,
uniqueTraders: (creatorId: string) =>
['creators', creatorId, 'unique-traders'] as const,
+ reserve: (creatorId: string) =>
+ ['creators', creatorId, 'reserve'] as const,
curveConfig: (creatorId: string) =>
['creators', creatorId, 'curve-config'] as const,
curveMigrations: (creatorId: string) =>
@@ -96,6 +98,10 @@ export const queryKeys = {
['creators', 'discovery', 'campaigns', wallet ?? ''] as const,
},
},
+ campaigns: {
+ all: () => ['campaigns'] as const,
+ list: (wallet?: string) => ['campaigns', 'list', wallet ?? ''] as const,
+ },
wallet: {
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
badges: (address: string) => ['wallet', address, 'badges'] as const,
diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx
old mode 100644
new mode 100755
index 72b6617e..2019ee33
--- a/src/pages/CampaignsPage.tsx
+++ b/src/pages/CampaignsPage.tsx
@@ -1,20 +1,66 @@
+import { Networks } from '@stellar/stellar-sdk';
import { useAccount } from 'wagmi';
import CampaignCard from '@/components/marketplace/CampaignCard';
import EmptyState from '@/components/common/EmptyState';
import Skeleton from '@/components/ui/skeleton';
-import { useActiveCampaigns } from '@/hooks/useCampaigns';
+import {
+ useCampaigns,
+ useClaimCampaignRewardMutation,
+} from '@/hooks/useCampaigns';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useNowMs } from '@/hooks/useNowMs';
-import { filterActiveCampaigns } from '@/utils/campaign.utils';
+import { useSigner } from '@/hooks/useSigner';
+import {
+ groupCampaignsByStatus,
+ type Campaign,
+ type CampaignStatus,
+} from '@/utils/campaign.utils';
+import { env } from '@/utils/env.utils';
-/** Full list of active seasonal campaigns, linked from the discovery page (#1226). */
+const networkPassphrase =
+ env.VITE_STELLAR_NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET;
+
+const SECTIONS: ReadonlyArray<{ status: CampaignStatus; title: string }> = [
+ { status: 'active', title: 'Active' },
+ { status: 'upcoming', title: 'Upcoming' },
+ { status: 'ended', title: 'Ended' },
+];
+
+/** Seasonal campaigns grouped into active, upcoming and ended, with reward claims (#1226, #1204). */
export default function CampaignsPage() {
useDocumentTitle('Campaigns — AccessLayer');
const { address } = useAccount();
- const { data, isLoading, isError, refetch } = useActiveCampaigns(address);
+ const wallet = address ?? '';
+ const { data, isLoading, isError, refetch } = useCampaigns(address);
+ const { activeSigner } = useSigner(networkPassphrase);
+ const claimMutation = useClaimCampaignRewardMutation(wallet, activeSigner);
const nowMs = useNowMs();
- const campaigns = filterActiveCampaigns(data ?? [], nowMs);
+ const groups = groupCampaignsByStatus(data ?? [], nowMs);
+ const hasCampaigns =
+ groups.active.length + groups.upcoming.length + groups.ended.length > 0;
+ const claimingId = claimMutation.isPending
+ ? claimMutation.variables
+ : undefined;
+
+ const renderGrid = (status: CampaignStatus, campaigns: Campaign[]) => (
+
+ {campaigns.map(campaign => (
+ claimMutation.mutate(campaignId)}
+ isClaiming={claimingId === campaign.id}
+ />
+ ))}
+
+ );
return (
@@ -25,7 +71,8 @@ export default function CampaignsPage() {
Seasonal reward campaigns for creator keys. Track how close your
- wallet is to qualifying before each campaign ends.
+ wallet is to qualifying, and claim your reward once a campaign
+ ends.
@@ -38,29 +85,34 @@ export default function CampaignsPage() {
) : isError ? (
void refetch() }}
className="mx-auto w-full max-w-xl"
/>
- ) : campaigns.length === 0 ? (
+ ) : !hasCampaigns ? (
) : (
-
- {campaigns.map(campaign => (
-
- ))}
+
+ {SECTIONS.filter(({ status }) => groups[status].length > 0).map(
+ ({ status, title }) => (
+
+
+ {title}
+
+ {renderGrid(status, groups[status])}
+
+ )
+ )}
)}
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index 4e5de26c..c536f1d8 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -77,6 +77,9 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useKeyTwap, type TwapWindow } from '@/hooks/useKeyTwap';
import { useKeyStats } from '@/hooks/useKeyStats';
import { useKeyUniqueTraders } from '@/hooks/useKeyUniqueTraders';
+import { useKeyReserve } from '@/hooks/useKeyReserve';
+import ReserveBalanceChip from '@/components/common/ReserveBalanceChip';
+import { getKeyReserveStatus } from '@/utils/keyReserve.utils';
import { useKeyConfig } from '@/hooks/useKeyConfig';
import KeyStatsPanel from '@/components/common/KeyStatsPanel';
import Skeleton from '@/components/ui/skeleton';
@@ -221,6 +224,8 @@ function CreatorDetailPageContent() {
const { data: uniqueTraders, isLoading: isUniqueTradersLoading } =
useKeyUniqueTraders(id || '');
+ const { data: keyReserve } = useKeyReserve(id || '');
+
const { data: keyConfig, isLoading: isKeyConfigLoading } = useKeyConfig(
id || ''
);
@@ -521,6 +526,15 @@ function CreatorDetailPageContent() {
: 10
),
},
+ // Reserve backstop (#1208); hidden until the key has accrued a reserve.
+ ...(keyReserve && getKeyReserveStatus(keyReserve).visible
+ ? [
+ {
+ label: 'Reserve Balance',
+ value: ,
+ },
+ ]
+ : []),
];
const chartData = (
diff --git a/src/pages/__tests__/CampaignsPage.test.tsx b/src/pages/__tests__/CampaignsPage.test.tsx
new file mode 100755
index 00000000..5860375c
--- /dev/null
+++ b/src/pages/__tests__/CampaignsPage.test.tsx
@@ -0,0 +1,170 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { useAccount } from 'wagmi';
+import CampaignsPage from '@/pages/CampaignsPage';
+import {
+ useCampaigns,
+ useClaimCampaignRewardMutation,
+} from '@/hooks/useCampaigns';
+import type { Campaign } from '@/utils/campaign.utils';
+
+vi.mock('@/hooks/useCampaigns', () => ({
+ useCampaigns: vi.fn(),
+ useClaimCampaignRewardMutation: vi.fn(),
+}));
+
+vi.mock('@/hooks/useSigner', () => ({
+ useSigner: () => ({ activeSigner: null }),
+}));
+
+const mockUseCampaigns = vi.mocked(useCampaigns);
+const mockUseClaim = vi.mocked(useClaimCampaignRewardMutation);
+const mockUseAccount = vi.mocked(useAccount);
+const NOW = Date.parse('2026-10-20T12:00:00Z');
+const HOUR = 60 * 60 * 1000;
+const WALLET = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H';
+
+function makeCampaign(id: string, overrides: Partial = {}): Campaign {
+ return {
+ id,
+ name: `Season ${id}`,
+ creatorId: `creator-${id}`,
+ keyName: 'NOVA',
+ criteria: { type: 'trade_volume', target: 100 },
+ rewardPoolXlm: 5000,
+ endsAt: new Date(NOW + HOUR).toISOString(),
+ walletProgress: 40,
+ ...overrides,
+ };
+}
+
+const mutate = vi.fn();
+
+function setup(data: Campaign[] | undefined, address: string | undefined = WALLET) {
+ mockUseAccount.mockReturnValue({
+ address,
+ isConnected: Boolean(address),
+ } as unknown as ReturnType);
+ mockUseCampaigns.mockReturnValue({
+ data,
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ } as unknown as ReturnType);
+ mockUseClaim.mockReturnValue({
+ mutate,
+ isPending: false,
+ variables: undefined,
+ } as unknown as ReturnType);
+
+ return render(
+
+
+
+ );
+}
+
+describe('CampaignsPage', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ mutate.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('groups campaigns into active, upcoming and ended sections', () => {
+ setup([
+ makeCampaign('a'),
+ makeCampaign('u', {
+ startsAt: new Date(NOW + 2 * HOUR).toISOString(),
+ endsAt: new Date(NOW + 48 * HOUR).toISOString(),
+ }),
+ makeCampaign('e', { endsAt: new Date(NOW - HOUR).toISOString() }),
+ ]);
+
+ expect(
+ within(screen.getByTestId('campaigns-active-grid')).getByTestId(
+ 'campaign-card-a'
+ )
+ ).toBeInTheDocument();
+ expect(
+ within(screen.getByTestId('campaigns-upcoming-grid')).getByTestId(
+ 'campaign-card-u'
+ )
+ ).toBeInTheDocument();
+ expect(
+ within(screen.getByTestId('campaigns-ended-grid')).getByTestId(
+ 'campaign-card-e'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('shows eligibility progress and a live countdown on active campaigns', () => {
+ setup([makeCampaign('a')]);
+ const card = screen.getByTestId('campaign-card-a');
+
+ expect(within(card).getByTestId('campaign-progress-label')).toHaveTextContent(
+ '40 / 100'
+ );
+ expect(within(card).getByTestId('campaign-time-remaining')).toHaveTextContent(
+ '1h 0m 0s'
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(5_000);
+ });
+
+ expect(within(card).getByTestId('campaign-time-remaining')).toHaveTextContent(
+ '59m 55s'
+ );
+ });
+
+ it('submits the claim for an ended campaign the wallet qualified for', () => {
+ setup([
+ makeCampaign('e', {
+ endsAt: new Date(NOW - HOUR).toISOString(),
+ walletProgress: 150,
+ }),
+ ]);
+
+ fireEvent.click(screen.getByTestId('campaign-claim-e'));
+
+ expect(mutate).toHaveBeenCalledWith('e');
+ });
+
+ it('does not offer a claim to wallets that missed the criteria', () => {
+ setup([
+ makeCampaign('e', {
+ endsAt: new Date(NOW - HOUR).toISOString(),
+ walletProgress: 10,
+ }),
+ ]);
+
+ expect(screen.queryByTestId('campaign-claim-e')).not.toBeInTheDocument();
+ });
+
+ it('shows the claimed badge instead of the claim button once claimed', () => {
+ setup([
+ makeCampaign('e', {
+ endsAt: new Date(NOW - HOUR).toISOString(),
+ walletProgress: 150,
+ claimed: true,
+ }),
+ ]);
+
+ expect(screen.getByTestId('campaign-claimed-badge')).toHaveTextContent(
+ 'Claimed'
+ );
+ expect(screen.queryByTestId('campaign-claim-e')).not.toBeInTheDocument();
+ });
+
+ it('shows the empty state when no campaigns exist', () => {
+ setup([]);
+
+ expect(screen.getByText('No campaigns yet')).toBeInTheDocument();
+ });
+});
diff --git a/src/services/campaignContract.service.ts b/src/services/campaignContract.service.ts
new file mode 100755
index 00000000..ca65e346
--- /dev/null
+++ b/src/services/campaignContract.service.ts
@@ -0,0 +1,128 @@
+import { Address, Networks } from '@stellar/stellar-sdk';
+import {
+ Client,
+ type AssembledTransaction,
+} from '@stellar/stellar-sdk/contract';
+import type { Signer } from '@/lib/signing/types';
+import { env } from '@/utils/env.utils';
+
+export interface CampaignContractClient {
+ claim_campaign_reward(args: {
+ campaign_id: string;
+ claimant: Address;
+ }): Promise>;
+}
+
+export type CampaignContractClientFactory = (
+ address: string | undefined,
+ signer: Signer
+) => Promise;
+
+export interface ClaimCampaignRewardInput {
+ /** Campaign id being claimed, forwarded to the contract call. */
+ campaignId: string;
+ /** Connected Stellar wallet address of the claimant. */
+ wallet: string;
+ /** Signer used to sign and submit the transaction. */
+ signer: Signer | null;
+}
+
+export interface ClaimCampaignRewardResult {
+ hash: string | null;
+}
+
+export class CampaignContractError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'CampaignContractError';
+ }
+}
+
+function getNetworkPassphrase(): string {
+ return env.VITE_STELLAR_NETWORK === 'mainnet'
+ ? Networks.PUBLIC
+ : Networks.TESTNET;
+}
+
+function parseAddress(value: string | undefined, label: string): Address {
+ if (!value) {
+ throw new CampaignContractError(`${label} is not configured.`);
+ }
+
+ try {
+ return Address.fromString(value);
+ } catch {
+ throw new CampaignContractError(
+ `${label} is not a valid Stellar address.`
+ );
+ }
+}
+
+const defaultFactory: CampaignContractClientFactory = async (
+ address,
+ signer
+) => {
+ if (!env.VITE_STELLAR_RPC_URL) {
+ throw new CampaignContractError(
+ 'VITE_STELLAR_RPC_URL is required for campaign contract calls.'
+ );
+ }
+ if (!env.VITE_CREATOR_KEYS_CONTRACT_ID) {
+ throw new CampaignContractError(
+ 'VITE_CREATOR_KEYS_CONTRACT_ID is required for campaign contract calls.'
+ );
+ }
+
+ const publicKey = address ?? (await signer.getPublicKey());
+
+ return Client.from({
+ contractId: env.VITE_CREATOR_KEYS_CONTRACT_ID,
+ rpcUrl: env.VITE_STELLAR_RPC_URL,
+ networkPassphrase: getNetworkPassphrase(),
+ signTransaction: async (transaction: string) => ({
+ signedTxXdr: await signer.sign(transaction),
+ }),
+ ...(publicKey ? { publicKey } : {}),
+ });
+};
+
+export class CampaignContractService {
+ private readonly clientFactory: CampaignContractClientFactory;
+
+ constructor(clientFactory: CampaignContractClientFactory = defaultFactory) {
+ this.clientFactory = clientFactory;
+ }
+
+ /**
+ * Signs and submits the on-chain `claim_campaign_reward` call for an
+ * ended campaign the wallet qualified for (#1204).
+ */
+ async claimCampaignReward({
+ campaignId,
+ wallet,
+ signer,
+ }: ClaimCampaignRewardInput): Promise {
+ if (!campaignId) {
+ throw new CampaignContractError('A campaign ID is required to claim.');
+ }
+ if (!signer) {
+ throw new CampaignContractError(
+ 'A connected wallet signer is required to claim campaign rewards.'
+ );
+ }
+
+ const claimant = parseAddress(wallet, 'Connected Stellar address');
+ const client = await this.clientFactory(wallet, signer);
+ const transaction = await client.claim_campaign_reward({
+ campaign_id: campaignId,
+ claimant,
+ });
+ const result = await transaction.signAndSend();
+
+ return {
+ hash: result.sendTransactionResponse?.hash ?? null,
+ };
+ }
+}
+
+export const campaignContractService = new CampaignContractService();
diff --git a/src/services/campaigns.service.ts b/src/services/campaigns.service.ts
index 3c1f84a3..06616046 100644
--- a/src/services/campaigns.service.ts
+++ b/src/services/campaigns.service.ts
@@ -23,6 +23,27 @@ class CampaignsService extends BaseApiService {
throw this.handleError(error);
}
}
+
+ /**
+ * Every campaign — upcoming, active and ended — with the wallet's progress
+ * and claim status when supplied (#1204).
+ * GET /campaigns?wallet=
+ */
+ async getCampaigns(wallet?: string): Promise {
+ try {
+ const response = await this.api.get>(
+ '/campaigns',
+ { params: wallet ? { wallet } : undefined }
+ );
+
+ const payload = response.data.data;
+ if (Array.isArray(payload)) return payload;
+ if (Array.isArray(payload?.campaigns)) return payload.campaigns;
+ return [];
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
}
export const campaignsService = new CampaignsService();
diff --git a/src/services/keyReserve.service.ts b/src/services/keyReserve.service.ts
new file mode 100755
index 00000000..7e5302ff
--- /dev/null
+++ b/src/services/keyReserve.service.ts
@@ -0,0 +1,22 @@
+import { BaseApiService, type APIResponse } from './api.service';
+import type { KeyReserve } from '@/utils/keyReserve.utils';
+
+class KeyReserveService extends BaseApiService {
+ /**
+ * Protocol reserve balance backing a creator key (#1208).
+ * GET /keys/:key_id/reserve
+ */
+ async getKeyReserve(keyId: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/keys/${keyId}/reserve`
+ );
+
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const keyReserveService = new KeyReserveService();
diff --git a/src/utils/__tests__/campaign.utils.test.ts b/src/utils/__tests__/campaign.utils.test.ts
index 9bab8ed9..a50717a5 100644
--- a/src/utils/__tests__/campaign.utils.test.ts
+++ b/src/utils/__tests__/campaign.utils.test.ts
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
+ canClaimCampaignReward,
describeCampaignCriteria,
filterActiveCampaigns,
getCampaignEligibility,
+ getCampaignStatus,
+ groupCampaignsByStatus,
type Campaign,
} from '@/utils/campaign.utils';
@@ -78,3 +81,66 @@ describe('filterActiveCampaigns', () => {
expect(result.map(c => c.id)).toEqual(['live']);
});
});
+
+describe('getCampaignStatus', () => {
+ const now = Date.parse('2026-10-20T12:00:00Z');
+
+ it('classifies upcoming, active and ended campaigns', () => {
+ expect(
+ getCampaignStatus(
+ { startsAt: '2026-10-21T00:00:00Z', endsAt: '2026-10-30T00:00:00Z' },
+ now
+ )
+ ).toBe('upcoming');
+ expect(getCampaignStatus({ endsAt: '2026-10-21T00:00:00Z' }, now)).toBe(
+ 'active'
+ );
+ expect(getCampaignStatus({ endsAt: '2026-10-19T00:00:00Z' }, now)).toBe(
+ 'ended'
+ );
+ expect(getCampaignStatus({ endsAt: 'not-a-date' }, now)).toBe('ended');
+ });
+});
+
+describe('groupCampaignsByStatus', () => {
+ it('orders active by soonest end and ended by most recent', () => {
+ const now = Date.parse('2026-10-20T12:00:00Z');
+ const groups = groupCampaignsByStatus(
+ [
+ makeCampaign('late', '2026-10-25T00:00:00Z'),
+ makeCampaign('soon', '2026-10-21T00:00:00Z'),
+ makeCampaign('old', '2026-10-01T00:00:00Z'),
+ makeCampaign('recent', '2026-10-19T00:00:00Z'),
+ ],
+ now
+ );
+
+ expect(groups.active.map(c => c.id)).toEqual(['soon', 'late']);
+ expect(groups.ended.map(c => c.id)).toEqual(['recent', 'old']);
+ expect(groups.upcoming).toEqual([]);
+ });
+});
+
+describe('canClaimCampaignReward', () => {
+ const now = Date.parse('2026-10-20T12:00:00Z');
+ const ended = makeCampaign('e', '2026-10-19T00:00:00Z');
+
+ it('allows eligible unclaimed wallets on ended campaigns only', () => {
+ expect(canClaimCampaignReward({ ...ended, walletProgress: 5 }, now)).toBe(
+ true
+ );
+ expect(
+ canClaimCampaignReward({ ...ended, walletProgress: 5, claimed: true }, now)
+ ).toBe(false);
+ expect(canClaimCampaignReward({ ...ended, walletProgress: 4 }, now)).toBe(
+ false
+ );
+ expect(canClaimCampaignReward(ended, now)).toBe(false);
+ expect(
+ canClaimCampaignReward(
+ { ...makeCampaign('a', '2026-10-25T00:00:00Z'), walletProgress: 5 },
+ now
+ )
+ ).toBe(false);
+ });
+});
diff --git a/src/utils/__tests__/keyReserve.utils.test.ts b/src/utils/__tests__/keyReserve.utils.test.ts
new file mode 100755
index 00000000..0ae743f0
--- /dev/null
+++ b/src/utils/__tests__/keyReserve.utils.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { getKeyReserveStatus } from '@/utils/keyReserve.utils';
+
+describe('getKeyReserveStatus', () => {
+ it('hides the chip when no reserve has accrued', () => {
+ expect(getKeyReserveStatus(null).visible).toBe(false);
+ expect(
+ getKeyReserveStatus({ reserveStroops: 0, marketCapStroops: 1_000 })
+ .visible
+ ).toBe(false);
+ });
+
+ it('warns when the reserve is below 10% of market cap', () => {
+ expect(
+ getKeyReserveStatus({ reserveStroops: 99, marketCapStroops: 1_000 })
+ ).toEqual({ visible: true, isLow: true, ratio: 0.099 });
+ });
+
+ it('does not warn at or above the 10% threshold', () => {
+ expect(
+ getKeyReserveStatus({ reserveStroops: 100, marketCapStroops: 1_000 })
+ .isLow
+ ).toBe(false);
+ expect(
+ getKeyReserveStatus({ reserveStroops: 500, marketCapStroops: 1_000 })
+ .isLow
+ ).toBe(false);
+ });
+
+ it('shows the balance without a warning when market cap is unknown', () => {
+ expect(
+ getKeyReserveStatus({ reserveStroops: 100, marketCapStroops: null })
+ ).toEqual({ visible: true, isLow: false, ratio: null });
+ });
+});
diff --git a/src/utils/campaign.utils.ts b/src/utils/campaign.utils.ts
index 66fee83c..e62af08b 100644
--- a/src/utils/campaign.utils.ts
+++ b/src/utils/campaign.utils.ts
@@ -16,10 +16,14 @@ export interface Campaign {
criteria: CampaignCriteria;
/** Total reward pool in XLM. */
rewardPoolXlm: number;
+ /** ISO timestamp when the campaign opens; absent once it has started. */
+ startsAt?: string | null;
/** ISO timestamp when the campaign closes. */
endsAt: string;
/** Connected wallet's progress toward `criteria.target`; absent when signed out. */
walletProgress?: number | null;
+ /** Whether the connected wallet already claimed this campaign's reward. */
+ claimed?: boolean;
}
export interface CampaignEligibility {
@@ -74,3 +78,62 @@ export function filterActiveCampaigns(
return Number.isFinite(endsAtMs) && endsAtMs > nowMs;
});
}
+
+export type CampaignStatus = 'upcoming' | 'active' | 'ended';
+
+export interface CampaignsByStatus {
+ active: Campaign[];
+ upcoming: Campaign[];
+ ended: Campaign[];
+}
+
+function parseTimeMs(value: string | null | undefined): number | null {
+ if (!value) return null;
+ const ms = new Date(value).getTime();
+ return Number.isFinite(ms) ? ms : null;
+}
+
+/** Where a campaign sits relative to `nowMs`; unparseable end times count as ended (#1204). */
+export function getCampaignStatus(
+ campaign: Pick,
+ nowMs: number
+): CampaignStatus {
+ const startsAtMs = parseTimeMs(campaign.startsAt);
+ if (startsAtMs != null && startsAtMs > nowMs) return 'upcoming';
+ const endsAtMs = parseTimeMs(campaign.endsAt);
+ if (endsAtMs == null || endsAtMs <= nowMs) return 'ended';
+ return 'active';
+}
+
+/**
+ * Splits campaigns into active, upcoming and ended lists (#1204). Active and
+ * upcoming campaigns are ordered by the next deadline; ended ones show the
+ * most recently closed first.
+ */
+export function groupCampaignsByStatus(
+ campaigns: readonly Campaign[],
+ nowMs: number
+): CampaignsByStatus {
+ const groups: CampaignsByStatus = { active: [], upcoming: [], ended: [] };
+ for (const campaign of campaigns) {
+ groups[getCampaignStatus(campaign, nowMs)].push(campaign);
+ }
+ const endsAt = (campaign: Campaign) => parseTimeMs(campaign.endsAt) ?? 0;
+ const startsAt = (campaign: Campaign) => parseTimeMs(campaign.startsAt) ?? 0;
+ groups.active.sort((a, b) => endsAt(a) - endsAt(b));
+ groups.upcoming.sort((a, b) => startsAt(a) - startsAt(b));
+ groups.ended.sort((a, b) => endsAt(b) - endsAt(a));
+ return groups;
+}
+
+/** An ended campaign the wallet qualified for and has not claimed yet (#1204). */
+export function canClaimCampaignReward(
+ campaign: Campaign,
+ nowMs: number
+): boolean {
+ if (campaign.claimed) return false;
+ if (getCampaignStatus(campaign, nowMs) !== 'ended') return false;
+ if (campaign.walletProgress == null) return false;
+ return getCampaignEligibility(campaign.walletProgress, campaign.criteria.target)
+ .eligible;
+}
diff --git a/src/utils/keyReserve.utils.ts b/src/utils/keyReserve.utils.ts
new file mode 100755
index 00000000..2e52e5b6
--- /dev/null
+++ b/src/utils/keyReserve.utils.ts
@@ -0,0 +1,44 @@
+/** Reserve backing a creator key, as returned by GET /keys/:key_id/reserve (#1208). */
+export interface KeyReserve {
+ /** Protocol reserve accrued for the key, in stroops. */
+ reserveStroops: number;
+ /** Current market cap of the key, in stroops. Null when unknown. */
+ marketCapStroops: number | null;
+}
+
+/** Reserves below this share of market cap are flagged as low (#1208). */
+export const LOW_RESERVE_RATIO = 0.1;
+
+export const RESERVE_TOOLTIP_TEXT =
+ 'The protocol reserve is a liquidity backstop funded by trading fees. It backs sells on the bonding curve and helps keep the key price stable.';
+
+export interface KeyReserveStatus {
+ /** False for keys that have not accrued any reserve yet. */
+ visible: boolean;
+ /** True when the reserve is below LOW_RESERVE_RATIO of market cap. */
+ isLow: boolean;
+ /** Reserve as a share of market cap, or null when market cap is unknown. */
+ ratio: number | null;
+}
+
+/** Decides whether to show the reserve chip and whether to warn (#1208). */
+export function getKeyReserveStatus(
+ reserve: KeyReserve | null | undefined
+): KeyReserveStatus {
+ const reserveStroops = reserve?.reserveStroops;
+ if (
+ reserveStroops == null ||
+ !Number.isFinite(reserveStroops) ||
+ reserveStroops <= 0
+ ) {
+ return { visible: false, isLow: false, ratio: null };
+ }
+
+ const marketCap = reserve?.marketCapStroops;
+ if (marketCap == null || !Number.isFinite(marketCap) || marketCap <= 0) {
+ return { visible: true, isLow: false, ratio: null };
+ }
+
+ const ratio = reserveStroops / marketCap;
+ return { visible: true, isLow: ratio < LOW_RESERVE_RATIO, ratio };
+}