diff --git a/frontend/components/voting/ReclaimVoteAction.test.tsx b/frontend/components/voting/ReclaimVoteAction.test.tsx
new file mode 100644
index 00000000..fe0a9684
--- /dev/null
+++ b/frontend/components/voting/ReclaimVoteAction.test.tsx
@@ -0,0 +1,150 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import ReclaimVoteAction from './ReclaimVoteAction';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import * as hooks from '@/hooks';
+import * as stacksLib from '@/lib/stacks';
+
+vi.mock('@/hooks', () => ({
+ useCurrentBlockHeight: vi.fn(),
+ useVote: vi.fn(),
+ useTransaction: vi.fn(),
+}));
+
+vi.mock('@/lib/stacks', () => ({
+ callReclaimVoteCost: vi.fn(),
+ invalidateStakeCache: vi.fn(),
+}));
+
+vi.mock('@/utils/formatSTX', () => ({
+ formatSTX: (amount: number) => (amount / 1000000).toString(),
+}));
+
+describe('ReclaimVoteAction', () => {
+ const mockProposal = {
+ id: 1,
+ title: 'Test Proposal',
+ proposer: 'ST1234',
+ amount: 1000,
+ description: 'Test',
+ votesFor: 0,
+ votesAgainst: 0,
+ executed: false,
+ createdAt: 100,
+ votingEndsAt: 200,
+ executionAllowedAt: 300,
+ };
+
+ const mockVote = {
+ proposalId: 1,
+ voter: 'ST1234',
+ support: true,
+ weight: 100,
+ costPaid: 10000000, // 10 STX
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders null if user Address is not provided', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null if vote is null or costPaid is 0', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: null, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows loading skeleton when vote data is loading', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: null, loading: true, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ const { container } = render( );
+ expect(container.querySelector('.animate-pulse')).toBeTruthy();
+ });
+
+ it('shows locked message if voting is still active', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 150, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ render( );
+
+ expect(screen.getByText(/You have 10 STX currently locked/i)).toBeTruthy();
+ const button = screen.getByRole('button', { name: /Reclaim 10 STX/i }) as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
+
+ it('shows available message and enables button if voting has ended', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ render( );
+
+ expect(screen.getByText(/The voting period has ended/i)).toBeTruthy();
+ expect(screen.getAllByText(/Available/i).length).toBeGreaterThan(0);
+
+ const button = screen.getByRole('button', { name: /Reclaim 10 STX/i }) as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ });
+
+ it('calls execute function when button is clicked', async () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+
+ const mockExecute = vi.fn().mockImplementation((cb) => cb());
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: mockExecute, isLoading: false, isError: false, isSuccess: false, isIdle: true, error: null, reset: vi.fn() });
+
+ render( );
+
+ const button = screen.getByRole('button', { name: /Reclaim 10 STX/i });
+ fireEvent.click(button);
+
+ expect(mockExecute).toHaveBeenCalled();
+ });
+
+ it('shows success message and explorer link when transaction is successful', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({
+ execute: vi.fn(),
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+ isIdle: false,
+ txId: '0x123',
+ error: null,
+ reset: vi.fn()
+ });
+
+ render( );
+
+ expect(screen.getByText(/Stake reclaimed successfully/i)).toBeTruthy();
+ const link = screen.getByRole('link', { name: /View Transaction/i });
+ expect(link).toBeTruthy();
+ expect(link.getAttribute('href')).toContain('0x123');
+ });
+
+ it('shows loading state when transaction is executing', () => {
+ vi.mocked(hooks.useCurrentBlockHeight).mockReturnValue({ blockHeight: 250, error: null, isLoading: false, refresh: vi.fn() });
+ vi.mocked(hooks.useVote).mockReturnValue({ vote: mockVote, loading: false, error: null, refresh: vi.fn() });
+ vi.mocked(hooks.useTransaction).mockReturnValue({ execute: vi.fn(), isLoading: true, isError: false, isSuccess: false, isIdle: false, error: null, reset: vi.fn() });
+
+ render( );
+
+ expect(screen.getByText(/Reclaiming STX.../i)).toBeTruthy();
+ const button = screen.getByRole('button') as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
+});
diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx
new file mode 100644
index 00000000..6b9e04e4
--- /dev/null
+++ b/frontend/components/voting/ReclaimVoteAction.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import React from 'react';
+import { Proposal } from '@/types';
+import { useCurrentBlockHeight, useVote, useTransaction } from '@/hooks';
+import { callReclaimVoteCost, invalidateStakeCache } from '@/lib/stacks';
+import { formatSTX } from '@/utils/formatSTX';
+import { Coins, Lock, CheckCircle, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react';
+
+/**
+ * Props for the ReclaimVoteAction component.
+ */
+interface ReclaimVoteActionProps {
+ /** The proposal object containing metadata like title and voting period */
+ proposal: Proposal;
+ /** The Stacks address of the current user */
+ userAddress?: string;
+ /** Optional callback triggered after a successful reclaim transaction */
+ onSuccess?: () => void;
+}
+
+/**
+ * Premium action component for reclaiming vote costs.
+ *
+ * This component provides a guided user interface for recovering the STX cost
+ * paid during quadratic voting. It automatically determines eligibility based
+ * on current block height and individual voting records.
+ *
+ * @param props The component props
+ */
+export default function ReclaimVoteAction({
+ proposal,
+ userAddress,
+ onSuccess
+}: ReclaimVoteActionProps) {
+ const { blockHeight } = useCurrentBlockHeight();
+ const { vote, loading: isVoteLoading, refresh: refreshVote } = useVote(proposal.id, userAddress);
+ const {
+ execute,
+ isLoading: isReclaiming,
+ isSuccess,
+ txId,
+ error,
+ reset: resetTransaction
+ } = useTransaction({
+ type: 'reclaim-vote',
+ proposalId: proposal.id,
+ title: proposal.title,
+ onSuccess: (txId) => {
+ refreshVote();
+ if (userAddress) {
+ invalidateStakeCache(userAddress);
+ }
+ onSuccess?.();
+ }
+ });
+
+ // Calculate if voting has ended based on block height
+ const isVotingActive = blockHeight ? blockHeight <= proposal.votingEndsAt : true;
+
+ // A user can reclaim if voting ended, they voted, and there is a cost to reclaim
+ const canReclaim = !isVotingActive && vote && vote.costPaid > 0;
+
+ // Render a loading state if vote data is pending
+ if (userAddress && isVoteLoading) {
+ return (
+
+ );
+ }
+
+ // Don't render if user hasn't voted or there's nothing to reclaim
+ if (!userAddress || !vote || vote.costPaid === 0) {
+ return null;
+ }
+
+ const handleReclaim = async () => {
+ try {
+ await execute(() => new Promise((resolve, reject) => {
+ callReclaimVoteCost(proposal.id, {
+ onFinish: (txId) => resolve(txId),
+ onCancel: () => reject(new Error('Transaction cancelled'))
+ });
+ }));
+ } catch (err) {
+ console.error('[SprintFund] Reclaim failed:', err);
+ }
+ };
+
+ return (
+
+
+
+ {isVotingActive ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Vote Cost Recovery
+
+ {!isVotingActive && (
+
+
+ Available
+
+ )}
+
+
+
+ {isVotingActive
+ ? `You have ${formatSTX(vote.costPaid)} STX currently locked by this vote. You can reclaim it once the voting period ends.`
+ : `The voting period has ended. You can now reclaim your ${formatSTX(vote.costPaid)} STX to make it available for future votes or withdrawal.`
+ }
+
+
+
+ {isReclaiming ? (
+ <>
+
+ Reclaiming STX...
+ >
+ ) : (
+ <>
+
+ Reclaim {formatSTX(vote.costPaid)} STX
+ >
+ )}
+
+
+
+
+
+ Note: Reclaiming vote cost does not remove your vote from the proposal tally. It simply releases the locked stake portion used for quadratic weight.
+
+
+
+
+
+ {isSuccess && (
+
+
+
+
+
+ Stake reclaimed successfully! Your available balance has been updated.
+
+ {txId && (
+
+ View Transaction
+
+ )}
+
+
+
+ )}
+
+ {error && (
+
+
+
+
+
+ Recovery failed: {error.message}
+
+
+ Dismiss
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/voting/index.ts b/frontend/components/voting/index.ts
index 1d1e9a68..2fb55a02 100644
--- a/frontend/components/voting/index.ts
+++ b/frontend/components/voting/index.ts
@@ -13,6 +13,7 @@ export { default as DelegatorCard } from '../DelegatorCard';
export { default as DelegatorMarketplace } from '../DelegatorMarketplace';
export { default as BulkVotingQueue } from '../BulkVotingQueue';
export { default as DelegationStats } from '../DelegationStats';
+export { default as ReclaimVoteAction } from './ReclaimVoteAction';
// Analytics and visualization
export { default as VotingAnalyticsDashboard } from '../VotingAnalyticsDashboard';
diff --git a/frontend/src/app/proposals/[id]/page.tsx b/frontend/src/app/proposals/[id]/page.tsx
index f432cf32..fc2e64ca 100644
--- a/frontend/src/app/proposals/[id]/page.tsx
+++ b/frontend/src/app/proposals/[id]/page.tsx
@@ -13,6 +13,8 @@ import { proposalExecutionService, type ExecutionHistoryEntry } from '@/services
import { ProposalCountdown } from '@/components/ProposalCountdown';
import { formatBlockHeight } from '@/lib/block-height';
import type { Proposal } from '@/types';
+import { useConnect } from '@stacks/connect-react';
+import ReclaimVoteAction from '@/components/voting/ReclaimVoteAction';
export default function ProposalDetailPage() {
const params = useParams();
@@ -21,6 +23,11 @@ export default function ProposalDetailPage() {
const [relatedProposals, setRelatedProposals] = useState([]);
const [executionStatus, setExecutionStatus] = useState(null);
const [loading, setLoading] = useState(true);
+
+ const { userSession } = useConnect();
+ const userAddress = userSession?.isUserSignedIn()
+ ? userSession.loadUserData().profile?.stxAddress?.mainnet
+ : undefined;
useEffect(() => {
const fetchProposalData = async () => {
@@ -163,6 +170,13 @@ export default function ProposalDetailPage() {
/>
)}
+ {proposal && userAddress && (
+
+ )}
+
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index a40d2076..9b0d4a54 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -11,3 +11,4 @@ export { useStxPriceData } from './useStxPrice';
export { useWalletBalanceData } from './useWalletBalance';
// Blockchain state hooks
export { useCurrentBlockHeight } from './useCurrentBlockHeight';
+export { useVote } from './useVote';
diff --git a/frontend/src/hooks/useVote.test.ts b/frontend/src/hooks/useVote.test.ts
new file mode 100644
index 00000000..14d3ffa0
--- /dev/null
+++ b/frontend/src/hooks/useVote.test.ts
@@ -0,0 +1,81 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { useVote } from './useVote';
+import * as stacksLib from '@/lib/stacks';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+vi.mock('@/lib/stacks', () => ({
+ getVote: vi.fn(),
+}));
+
+describe('useVote', () => {
+ const mockVote = {
+ proposalId: 1,
+ voter: 'ST1234',
+ support: true,
+ weight: 100,
+ costPaid: 10,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return null when voterAddress is missing', async () => {
+ const { result } = renderHook(() => useVote(1, undefined));
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.vote).toBeNull();
+ expect(stacksLib.getVote).not.toHaveBeenCalled();
+ });
+
+ it('should fetch and return vote data', async () => {
+ vi.mocked(stacksLib.getVote).mockResolvedValueOnce(mockVote);
+
+ const { result } = renderHook(() => useVote(1, 'ST1234'));
+
+ expect(result.current.loading).toBe(true);
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.vote).toEqual(mockVote);
+ expect(result.current.error).toBeNull();
+ expect(stacksLib.getVote).toHaveBeenCalledWith(1, 'ST1234');
+ });
+
+ it('should handle errors from getVote', async () => {
+ vi.mocked(stacksLib.getVote).mockRejectedValueOnce(new Error('Network error'));
+
+ const { result } = renderHook(() => useVote(1, 'ST1234'));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.vote).toBeNull();
+ expect(result.current.error).toEqual(new Error('Network error'));
+ });
+
+ it('should allow manual refresh', async () => {
+ vi.mocked(stacksLib.getVote)
+ .mockResolvedValueOnce(mockVote)
+ .mockResolvedValueOnce({ ...mockVote, costPaid: 0 });
+
+ const { result } = renderHook(() => useVote(1, 'ST1234'));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.vote?.costPaid).toBe(10);
+
+ result.current.refresh();
+
+ await waitFor(() => {
+ expect(result.current.vote?.costPaid).toBe(0);
+ });
+
+ expect(stacksLib.getVote).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/hooks/useVote.ts b/frontend/src/hooks/useVote.ts
new file mode 100644
index 00000000..4f2586ef
--- /dev/null
+++ b/frontend/src/hooks/useVote.ts
@@ -0,0 +1,42 @@
+import { useState, useEffect, useCallback } from 'react';
+import { getVote } from '@/lib/stacks';
+import type { VoteRecord } from '@/types';
+
+/**
+ * Custom hook to fetch and track a specific user's voting record for a proposal.
+ * This is essential for managing the lifecycle of quadratic voting costs and
+ * verifying reclaim eligibility once a voting period has expired.
+ *
+ * @param proposalId The unique identifier of the proposal
+ * @param voterAddress The Stacks address of the voter (optional)
+ * @returns Object containing the vote record, loading state, error, and refresh function
+ */
+export function useVote(proposalId: number, voterAddress?: string) {
+ const [vote, setVote] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchVote = useCallback(async () => {
+ if (!voterAddress) {
+ setVote(null);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await getVote(proposalId, voterAddress);
+ setVote(data);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch vote'));
+ } finally {
+ setLoading(false);
+ }
+ }, [proposalId, voterAddress]);
+
+ useEffect(() => {
+ fetchVote();
+ }, [fetchVote]);
+
+ return { vote, loading, error, refresh: fetchVote };
+}
diff --git a/frontend/src/lib/blockchain-cache.ts b/frontend/src/lib/blockchain-cache.ts
index ad664f85..11766c93 100644
--- a/frontend/src/lib/blockchain-cache.ts
+++ b/frontend/src/lib/blockchain-cache.ts
@@ -1,4 +1,4 @@
-import type { Proposal, ProposalPage } from '../types';
+import type { Proposal, ProposalPage, VoteRecord } from '../types';
interface CacheEntry {
data: T;
@@ -19,6 +19,7 @@ class BlockchainDataCache {
private proposalCounts = new Map>();
private stakes = new Map>();
private minStakeAmounts = new Map>();
+ private votes = new Map>();
private stats: CacheStats = {
hits: 0,
@@ -96,6 +97,16 @@ class BlockchainDataCache {
getMinStakeAmount(): number | null {
return this.getCachedEntry(this.minStakeAmounts.get('global'));
}
+
+ setVote(proposalId: number, voter: string, vote: VoteRecord, ttl: number = this.DEFAULT_TTL_MS): void {
+ const key = `${proposalId}-${voter}`;
+ this.votes.set(key, { data: vote, timestamp: Date.now(), ttl });
+ }
+
+ getVote(proposalId: number, voter: string): VoteRecord | null {
+ const key = `${proposalId}-${voter}`;
+ return this.getCachedEntry(this.votes.get(key));
+ }
private getCachedEntry(entry: CacheEntry | undefined): T | null {
if (!entry) {
@@ -129,12 +140,24 @@ class BlockchainDataCache {
this.stakes.delete(address);
}
+ /**
+ * Invalidate a specific voting record in the cache.
+ * Forces a refresh from the blockchain on the next fetch attempt.
+ *
+ * @param proposalId The ID of the proposal
+ * @param voter The Stacks address of the voter
+ */
+ invalidateVote(proposalId: number, voter: string): void {
+ this.votes.delete(`${proposalId}-${voter}`);
+ }
+
invalidateAll(): void {
this.proposals.clear();
this.proposalPages.clear();
this.proposalCounts.clear();
this.stakes.clear();
this.minStakeAmounts.clear();
+ this.votes.clear();
}
getStats(): CacheStats {
@@ -163,7 +186,8 @@ class BlockchainDataCache {
this.proposalPages.size +
this.proposalCounts.size +
this.stakes.size +
- this.minStakeAmounts.size
+ this.minStakeAmounts.size +
+ this.votes.size
);
}
diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts
index c8aea3fb..264b6cac 100644
--- a/frontend/src/lib/stacks.ts
+++ b/frontend/src/lib/stacks.ts
@@ -11,7 +11,7 @@ import { CONTRACT_ADDRESS, CONTRACT_NAME, CONTRACT_PRINCIPAL, NETWORK } from '..
import { sanitizeText, sanitizeMultilineText } from './sanitize';
import { blockchainCache } from './blockchain-cache';
import { AsyncError, ErrorCode } from './async-errors';
-import type { Proposal, ProposalPage } from '../types';
+import type { Proposal, ProposalPage, StakeInfo, VoteRecord } from '../types';
import type {
ProposalCountResponse,
ProposalResponse,
@@ -20,6 +20,7 @@ import type {
TxCallbacks,
RawProposal,
RawStake,
+ RawVote,
} from '../types/contract';
import {
validateRawProposal,
@@ -422,6 +423,52 @@ export function callExecuteProposal(proposalId: number, cb: TxCallbacks): void {
});
}
+/**
+ * Submit a transaction to reclaim the STX cost paid during quadratic voting.
+ * This is only allowed after the voting period for the specified proposal has ended.
+ *
+ * @param proposalId The ID of the proposal to recover costs from
+ * @param cb Object containing onFinish (txId) and onCancel callbacks
+ */
+export function callReclaimVoteCost(proposalId: number, cb: TxCallbacks): void {
+ contractCall({
+ functionName: 'reclaim-vote-cost',
+ functionArgs: [uintCV(proposalId)],
+ cb,
+ });
+}
+
+/**
+ * Fetch a specific user's vote on a proposal.
+ * @param proposalId ID of the proposal
+ * @param voter Principal of the voter
+ * @returns VoteRecord or null if no vote was found
+ */
+export async function getVote(proposalId: number, voter: string): Promise {
+ try {
+ const cached = blockchainCache.getVote(proposalId, voter);
+ if (cached !== null) {
+ return cached;
+ }
+
+ const raw = await readOnly('get-vote', [
+ uintCV(proposalId),
+ principalCV(voter),
+ ]);
+
+ if (!raw) {
+ return null;
+ }
+
+ const vote = convertRawToVote(proposalId, voter, raw);
+ blockchainCache.setVote(proposalId, voter, vote);
+ return vote;
+ } catch (err) {
+ console.error(`[SprintFund] Failed to fetch vote for prop ${proposalId}:`, err);
+ return null;
+ }
+}
+
export function invalidateProposalCache(proposalId: number): void {
blockchainCache.invalidateProposal(proposalId);
}
@@ -438,6 +485,10 @@ export function invalidateStakeCache(address: string): void {
blockchainCache.invalidateStake(address);
}
+export function invalidateVoteCache(proposalId: number, voter: string): void {
+ blockchainCache.invalidateVote(proposalId, voter);
+}
+
export function invalidateAllBlockchainCache(): void {
blockchainCache.invalidateAll();
}
diff --git a/frontend/src/lib/type-converters.ts b/frontend/src/lib/type-converters.ts
index f6f615ee..f1cda2d1 100644
--- a/frontend/src/lib/type-converters.ts
+++ b/frontend/src/lib/type-converters.ts
@@ -60,6 +60,7 @@ export function convertRawToVote(proposalId: number, voter: string, raw: RawVote
voter,
support: unwrapClarityValue(raw.support) ?? false,
weight: unwrapClarityValue(raw.weight) ?? 0,
+ costPaid: unwrapClarityValue(raw['cost-paid']) ?? 0,
};
}
diff --git a/frontend/src/types/contract.ts b/frontend/src/types/contract.ts
index 7036ad75..e3bca567 100644
--- a/frontend/src/types/contract.ts
+++ b/frontend/src/types/contract.ts
@@ -54,6 +54,7 @@ export interface RawProposal {
*/
export interface RawStake {
amount: ClarityValue;
+ 'locked-until': ClarityValue;
}
/**
@@ -64,6 +65,7 @@ export interface RawStake {
export interface RawVote {
weight: ClarityValue;
support: ClarityValue;
+ 'cost-paid': ClarityValue;
}
/* ═══════════════════════════════════════════════
@@ -141,6 +143,9 @@ export const CONTRACT_ERROR_CODES = {
110: 'ERR-ZERO-AMOUNT',
111: 'ERR-INSUFFICIENT-BALANCE',
112: 'ERR-PROPOSAL-EXPIRED',
+ 113: 'ERR-PROPOSAL-CANCELLED',
+ 114: 'ERR-STAKE-LOCKED',
+ 115: 'ERR-TIMELOCK-ACTIVE',
} as const;
export type ContractErrorCode = keyof typeof CONTRACT_ERROR_CODES;
diff --git a/frontend/src/types/transaction.ts b/frontend/src/types/transaction.ts
index 08bba748..c66bc3b4 100644
--- a/frontend/src/types/transaction.ts
+++ b/frontend/src/types/transaction.ts
@@ -1,4 +1,4 @@
-export type TransactionType = 'stake' | 'vote' | 'create-proposal' | 'execute' | 'unstake';
+export type TransactionType = 'stake' | 'vote' | 'create-proposal' | 'execute' | 'unstake' | 'reclaim-vote';
export type TransactionStatus = 'pending' | 'confirmed' | 'failed' | 'dropped';
diff --git a/frontend/src/types/voting.ts b/frontend/src/types/voting.ts
index 940cedc1..c8cfeaec 100644
--- a/frontend/src/types/voting.ts
+++ b/frontend/src/types/voting.ts
@@ -19,6 +19,7 @@ export interface VoteRecord {
voter: string;
support: boolean;
weight: number;
+ costPaid: number;
}
/**
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index c9f70ffa..8ed1b936 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -22,7 +22,7 @@ export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
- include: ['src/**/*.test.{ts,tsx}'],
+ include: ['src/**/*.test.{ts,tsx}', 'components/**/*.test.{ts,tsx}', 'utils/**/*.test.{ts,tsx}'],
exclude: ['node_modules/**'],
setupFiles: [resolve(configDir, 'src/test/vitest.setup.ts')],
globals: true,