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.` + } +

+ + + +
+ +

+ 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} +

+ +
+
+
+ )} +
+ ); +} 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,