From 67ba659cd9c7ac60198b9f43a3da5596c5a200e4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:29:52 +0100 Subject: [PATCH 01/30] Initialize branch for vote-cost reclaim feature From 08e424834f0e41fd01e02f494c346e7fd850f121 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:30:47 +0100 Subject: [PATCH 02/30] Update contract types to include missing vote-cost fields and error codes --- frontend/src/types/contract.ts | 5 +++++ 1 file changed, 5 insertions(+) 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; From 5289967f7b0709ab2c931ffdb6f706aa65e6ac2b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:32:04 +0100 Subject: [PATCH 03/30] Add costPaid field to VoteRecord type for reclaim support --- frontend/src/types/voting.ts | 1 + 1 file changed, 1 insertion(+) 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; } /** From 086ffdc4b16d333ce17db583380284fb7e38f3d5 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:32:21 +0100 Subject: [PATCH 04/30] Update vote converter to map cost-paid field from contract data --- frontend/src/lib/type-converters.ts | 1 + 1 file changed, 1 insertion(+) 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, }; } From 1369679fad63ac41f6b73fa2aa2d00c7d33803e7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:33:15 +0100 Subject: [PATCH 05/30] Extend blockchain cache to support vote record storage --- frontend/src/lib/blockchain-cache.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/blockchain-cache.ts b/frontend/src/lib/blockchain-cache.ts index ad664f85..6aa5383a 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,17 @@ class BlockchainDataCache { this.stakes.delete(address); } + 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 +179,8 @@ class BlockchainDataCache { this.proposalPages.size + this.proposalCounts.size + this.stakes.size + - this.minStakeAmounts.size + this.minStakeAmounts.size + + this.votes.size ); } From d03b4338cd60290a5ac10e53a73a496470ee7834 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:34:25 +0100 Subject: [PATCH 06/30] Expose get-vote read-only helper and reclaim-vote-cost transaction in stacks.ts --- frontend/src/lib/stacks.ts | 51 +++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index c8aea3fb..3fde4296 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,50 @@ export function callExecuteProposal(proposalId: number, cb: TxCallbacks): void { }); } +/** + * Submit a transaction to reclaim vote cost after a proposal ends. + * @param proposalId ID of the proposal to reclaim from + * @param cb Callbacks for transaction completion or cancellation + */ +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 +483,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(); } From 1cb2282fc18a49be0f523e030f9feb4b063738f4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:35:07 +0100 Subject: [PATCH 07/30] Add reclaim-vote transaction type --- frontend/src/types/transaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'; From 3e047915246115e7abe75574aa1d42c70aa08a1b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:36:10 +0100 Subject: [PATCH 08/30] Implement useVote hook for fetching on-chain voting records --- frontend/src/hooks/useVote.ts | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 frontend/src/hooks/useVote.ts diff --git a/frontend/src/hooks/useVote.ts b/frontend/src/hooks/useVote.ts new file mode 100644 index 00000000..8a1bb55c --- /dev/null +++ b/frontend/src/hooks/useVote.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getVote } from '@/lib/stacks'; +import type { VoteRecord } from '@/types'; + +/** + * Hook to fetch and track a user's vote on a specific proposal. + * Useful for checking eligibility for reclaim operations. + * + * @param proposalId The ID of the proposal to check + * @param voterAddress The Stacks address of the voter + */ +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 }; +} From ab382e87224870806e84d1f822c67ffeae47a61b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:36:37 +0100 Subject: [PATCH 09/30] Export useVote hook from main index --- frontend/src/hooks/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From 284bd01911b84e9e0c8109a8ad37d01062bc64a7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 16:37:37 +0100 Subject: [PATCH 10/30] Create ReclaimVoteAction component for vote-cost recovery flow --- frontend/components/ReclaimVoteAction.tsx | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 frontend/components/ReclaimVoteAction.tsx diff --git a/frontend/components/ReclaimVoteAction.tsx b/frontend/components/ReclaimVoteAction.tsx new file mode 100644 index 00000000..426b7c98 --- /dev/null +++ b/frontend/components/ReclaimVoteAction.tsx @@ -0,0 +1,128 @@ +'use client'; + +import React from 'react'; +import { Proposal } from '@/types'; +import { useCurrentBlockHeight, useVote, useTransaction } from '@/hooks'; +import { callReclaimVoteCost } from '@/lib/stacks'; +import { formatSTX } from '@/utils/formatSTX'; +import { Coins, Lock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'; + +interface ReclaimVoteActionProps { + proposal: Proposal; + userAddress?: string; + onSuccess?: () => void; +} + +/** + * Premium action component for reclaiming vote costs. + * Guides the user through the recovery process once a voting period ends. + */ +export default function ReclaimVoteAction({ + proposal, + userAddress, + onSuccess +}: ReclaimVoteActionProps) { + const { blockHeight } = useCurrentBlockHeight(); + const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); + const { execute, isLoading: isReclaiming } = useTransaction({ + type: 'reclaim-vote', + proposalId: proposal.id, + title: proposal.title, + onSuccess: (txId) => { + refreshVote(); + 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; + + // 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. +

+
+
+
+
+ ); +} From 0f5ab941eec051c0e033880dddae1710f92a2d84 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 23 Apr 2026 19:38:37 +0100 Subject: [PATCH 11/30] Integrate ReclaimVoteAction component into the ProposalDetailPage --- frontend/src/app/proposals/[id]/page.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/app/proposals/[id]/page.tsx b/frontend/src/app/proposals/[id]/page.tsx index f432cf32..86fef99d 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/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 && ( + + )} + From b7d40f4def9d9d990e0c21c4a495a08c99c394b4 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 11:39:38 +0100 Subject: [PATCH 12/30] Add unit tests for useVote hook --- frontend/src/hooks/useVote.test.ts | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 frontend/src/hooks/useVote.test.ts 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); + }); +}); From 9efc860da47b26d647abe0df251df04e9117cd15 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 11:42:26 +0100 Subject: [PATCH 13/30] Add unit tests for ReclaimVoteAction component --- .../components/ReclaimVoteAction.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 frontend/components/ReclaimVoteAction.test.tsx diff --git a/frontend/components/ReclaimVoteAction.test.tsx b/frontend/components/ReclaimVoteAction.test.tsx new file mode 100644 index 00000000..46cf8ca4 --- /dev/null +++ b/frontend/components/ReclaimVoteAction.test.tsx @@ -0,0 +1,118 @@ +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(), +})); + +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 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)).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /Reclaim 10 STX/i }); + expect(button).toBeDisabled(); + }); + + 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)).toBeInTheDocument(); + expect(screen.getByText(/Available/i)).toBeInTheDocument(); + + const button = screen.getByRole('button', { name: /Reclaim 10 STX/i }); + expect(button).not.toBeDisabled(); + }); + + 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 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)).toBeInTheDocument(); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); +}); From 35dc385fb3543aa48bb730205f06d617acdfb08a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 11:45:26 +0100 Subject: [PATCH 14/30] Invalidate stake cache on successful reclaim to reflect updated available stake --- frontend/components/ReclaimVoteAction.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/components/ReclaimVoteAction.tsx b/frontend/components/ReclaimVoteAction.tsx index 426b7c98..73c70ff7 100644 --- a/frontend/components/ReclaimVoteAction.tsx +++ b/frontend/components/ReclaimVoteAction.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Proposal } from '@/types'; import { useCurrentBlockHeight, useVote, useTransaction } from '@/hooks'; -import { callReclaimVoteCost } from '@/lib/stacks'; +import { callReclaimVoteCost, invalidateStakeCache } from '@/lib/stacks'; import { formatSTX } from '@/utils/formatSTX'; import { Coins, Lock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'; @@ -30,6 +30,9 @@ export default function ReclaimVoteAction({ title: proposal.title, onSuccess: (txId) => { refreshVote(); + if (userAddress) { + invalidateStakeCache(userAddress); + } onSuccess?.(); } }); From 9b74f71fa5d1fe1522e2c9c18ae5963208305263 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:03:11 +0100 Subject: [PATCH 15/30] Update ReclaimVoteAction tests to mock invalidateStakeCache --- frontend/components/ReclaimVoteAction.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/ReclaimVoteAction.test.tsx b/frontend/components/ReclaimVoteAction.test.tsx index 46cf8ca4..fcab52a9 100644 --- a/frontend/components/ReclaimVoteAction.test.tsx +++ b/frontend/components/ReclaimVoteAction.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/hooks', () => ({ vi.mock('@/lib/stacks', () => ({ callReclaimVoteCost: vi.fn(), + invalidateStakeCache: vi.fn(), })); vi.mock('@/utils/formatSTX', () => ({ From c3d9f86dc3a0b189bbc18bbe712e05919926f3de Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:06:28 +0100 Subject: [PATCH 16/30] Update vitest config to include tests in components and utils directories --- frontend/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From e01b878d76341bfcfb81d9f798200d41a4c01bc9 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:31:42 +0100 Subject: [PATCH 17/30] Fix test matching multiple elements for Available text --- .../components/ReclaimVoteAction.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/components/ReclaimVoteAction.test.tsx b/frontend/components/ReclaimVoteAction.test.tsx index fcab52a9..ba870a80 100644 --- a/frontend/components/ReclaimVoteAction.test.tsx +++ b/frontend/components/ReclaimVoteAction.test.tsx @@ -71,9 +71,9 @@ describe('ReclaimVoteAction', () => { render(); - expect(screen.getByText(/You have 10 STX currently locked/i)).toBeInTheDocument(); - const button = screen.getByRole('button', { name: /Reclaim 10 STX/i }); - expect(button).toBeDisabled(); + 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', () => { @@ -83,11 +83,11 @@ describe('ReclaimVoteAction', () => { render(); - expect(screen.getByText(/The voting period has ended/i)).toBeInTheDocument(); - expect(screen.getByText(/Available/i)).toBeInTheDocument(); + 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 }); - expect(button).not.toBeDisabled(); + 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 () => { @@ -112,8 +112,8 @@ describe('ReclaimVoteAction', () => { render(); - expect(screen.getByText(/Reclaiming STX.../i)).toBeInTheDocument(); - const button = screen.getByRole('button'); - expect(button).toBeDisabled(); + expect(screen.getByText(/Reclaiming STX.../i)).toBeTruthy(); + const button = screen.getByRole('button') as HTMLButtonElement; + expect(button.disabled).toBe(true); }); }); From 77cf67fad4192ff46ac353620af39b97816f8484 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:36:54 +0100 Subject: [PATCH 18/30] Organize ReclaimVoteAction in voting directory and update barrel exports --- .../voting/ReclaimVoteAction.test.tsx | 119 ++++++++++++++++ .../components/voting/ReclaimVoteAction.tsx | 131 ++++++++++++++++++ frontend/components/voting/index.ts | 1 + frontend/src/app/proposals/[id]/page.tsx | 2 +- 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 frontend/components/voting/ReclaimVoteAction.test.tsx create mode 100644 frontend/components/voting/ReclaimVoteAction.tsx diff --git a/frontend/components/voting/ReclaimVoteAction.test.tsx b/frontend/components/voting/ReclaimVoteAction.test.tsx new file mode 100644 index 00000000..ba870a80 --- /dev/null +++ b/frontend/components/voting/ReclaimVoteAction.test.tsx @@ -0,0 +1,119 @@ +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 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 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..73c70ff7 --- /dev/null +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -0,0 +1,131 @@ +'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 } from 'lucide-react'; + +interface ReclaimVoteActionProps { + proposal: Proposal; + userAddress?: string; + onSuccess?: () => void; +} + +/** + * Premium action component for reclaiming vote costs. + * Guides the user through the recovery process once a voting period ends. + */ +export default function ReclaimVoteAction({ + proposal, + userAddress, + onSuccess +}: ReclaimVoteActionProps) { + const { blockHeight } = useCurrentBlockHeight(); + const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); + const { execute, isLoading: isReclaiming } = 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; + + // 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. +

+
+
+
+
+ ); +} 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 86fef99d..fc2e64ca 100644 --- a/frontend/src/app/proposals/[id]/page.tsx +++ b/frontend/src/app/proposals/[id]/page.tsx @@ -14,7 +14,7 @@ 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/ReclaimVoteAction'; +import ReclaimVoteAction from '@/components/voting/ReclaimVoteAction'; export default function ProposalDetailPage() { const params = useParams(); From dda2406b1101a8e3c33f8ed049fcd3021f0dba9e Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:37:07 +0100 Subject: [PATCH 19/30] Remove old component files after move --- .../components/ReclaimVoteAction.test.tsx | 119 ---------------- frontend/components/ReclaimVoteAction.tsx | 131 ------------------ 2 files changed, 250 deletions(-) delete mode 100644 frontend/components/ReclaimVoteAction.test.tsx delete mode 100644 frontend/components/ReclaimVoteAction.tsx diff --git a/frontend/components/ReclaimVoteAction.test.tsx b/frontend/components/ReclaimVoteAction.test.tsx deleted file mode 100644 index ba870a80..00000000 --- a/frontend/components/ReclaimVoteAction.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -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 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 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/ReclaimVoteAction.tsx b/frontend/components/ReclaimVoteAction.tsx deleted file mode 100644 index 73c70ff7..00000000 --- a/frontend/components/ReclaimVoteAction.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'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 } from 'lucide-react'; - -interface ReclaimVoteActionProps { - proposal: Proposal; - userAddress?: string; - onSuccess?: () => void; -} - -/** - * Premium action component for reclaiming vote costs. - * Guides the user through the recovery process once a voting period ends. - */ -export default function ReclaimVoteAction({ - proposal, - userAddress, - onSuccess -}: ReclaimVoteActionProps) { - const { blockHeight } = useCurrentBlockHeight(); - const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); - const { execute, isLoading: isReclaiming } = 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; - - // 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. -

-
-
-
-
- ); -} From 98b674072e80e6fe7859ce94c180a4780345266c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:39:45 +0100 Subject: [PATCH 20/30] Enhance JSDoc documentation for useVote hook --- frontend/src/hooks/useVote.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useVote.ts b/frontend/src/hooks/useVote.ts index 8a1bb55c..4f2586ef 100644 --- a/frontend/src/hooks/useVote.ts +++ b/frontend/src/hooks/useVote.ts @@ -3,11 +3,13 @@ import { getVote } from '@/lib/stacks'; import type { VoteRecord } from '@/types'; /** - * Hook to fetch and track a user's vote on a specific proposal. - * Useful for checking eligibility for reclaim operations. + * 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 ID of the proposal to check - * @param voterAddress The Stacks address of the voter + * @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); From 77a6323dffc3ee16d9987c5735a48e5a592d5764 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:39:57 +0100 Subject: [PATCH 21/30] Enhance JSDoc documentation for ReclaimVoteAction component --- frontend/components/voting/ReclaimVoteAction.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 73c70ff7..92b00036 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -7,15 +7,26 @@ import { callReclaimVoteCost, invalidateStakeCache } from '@/lib/stacks'; import { formatSTX } from '@/utils/formatSTX'; import { Coins, Lock, CheckCircle, AlertCircle, RefreshCw } 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. - * Guides the user through the recovery process once a voting period ends. + * + * 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, From 680bfd9c137562a18791bdf95353b12b69eb8053 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:41:45 +0100 Subject: [PATCH 22/30] Enhance JSDoc documentation for reclaim and vote API helpers in stacks.ts --- frontend/src/lib/stacks.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/stacks.ts b/frontend/src/lib/stacks.ts index 3fde4296..264b6cac 100644 --- a/frontend/src/lib/stacks.ts +++ b/frontend/src/lib/stacks.ts @@ -424,9 +424,11 @@ export function callExecuteProposal(proposalId: number, cb: TxCallbacks): void { } /** - * Submit a transaction to reclaim vote cost after a proposal ends. - * @param proposalId ID of the proposal to reclaim from - * @param cb Callbacks for transaction completion or cancellation + * 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({ From 0583d1f7259e4f2ee37efdca979ab3de833d96ff Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:42:07 +0100 Subject: [PATCH 23/30] Add success message and state feedback to ReclaimVoteAction component --- frontend/components/voting/ReclaimVoteAction.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 92b00036..0172920a 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -35,7 +35,7 @@ export default function ReclaimVoteAction({ }: ReclaimVoteActionProps) { const { blockHeight } = useCurrentBlockHeight(); const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); - const { execute, isLoading: isReclaiming } = useTransaction({ + const { execute, isLoading: isReclaiming, isSuccess } = useTransaction({ type: 'reclaim-vote', proposalId: proposal.id, title: proposal.title, @@ -137,6 +137,17 @@ export default function ReclaimVoteAction({ + + {isSuccess && ( +
+
+ +

+ Stake reclaimed successfully! Your available balance has been updated. +

+
+
+ )} ); } From 7633c29fcb9b6bda4f5e5597e9c06728cea717ac Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:42:29 +0100 Subject: [PATCH 24/30] Add error handling and retry UI to ReclaimVoteAction component --- .../components/voting/ReclaimVoteAction.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 0172920a..8216f436 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -35,7 +35,13 @@ export default function ReclaimVoteAction({ }: ReclaimVoteActionProps) { const { blockHeight } = useCurrentBlockHeight(); const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); - const { execute, isLoading: isReclaiming, isSuccess } = useTransaction({ + const { + execute, + isLoading: isReclaiming, + isSuccess, + error, + reset: resetTransaction + } = useTransaction({ type: 'reclaim-vote', proposalId: proposal.id, title: proposal.title, @@ -148,6 +154,25 @@ export default function ReclaimVoteAction({ )} + + {error && ( +
+
+ +
+

+ Recovery failed: {error.message} +

+ +
+
+
+ )} ); } From a2294b213fb5c2960ad09279b883a1493e2645f2 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:42:45 +0100 Subject: [PATCH 25/30] Add loading skeleton state to ReclaimVoteAction component --- .../components/voting/ReclaimVoteAction.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 8216f436..1ed3fca6 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -34,7 +34,7 @@ export default function ReclaimVoteAction({ onSuccess }: ReclaimVoteActionProps) { const { blockHeight } = useCurrentBlockHeight(); - const { vote, refresh: refreshVote } = useVote(proposal.id, userAddress); + const { vote, loading: isVoteLoading, refresh: refreshVote } = useVote(proposal.id, userAddress); const { execute, isLoading: isReclaiming, @@ -60,6 +60,22 @@ export default function ReclaimVoteAction({ // 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; From 63df895cf5584e88cbf5896c65041e72f103882b Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:42:58 +0100 Subject: [PATCH 26/30] Add unit test for loading skeleton state in ReclaimVoteAction component --- frontend/components/voting/ReclaimVoteAction.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/components/voting/ReclaimVoteAction.test.tsx b/frontend/components/voting/ReclaimVoteAction.test.tsx index ba870a80..6ee70ff0 100644 --- a/frontend/components/voting/ReclaimVoteAction.test.tsx +++ b/frontend/components/voting/ReclaimVoteAction.test.tsx @@ -64,6 +64,15 @@ describe('ReclaimVoteAction', () => { 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() }); From 5a8139d0cc90a0e43b6a6ccaf0e63ca8c7388149 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:43:28 +0100 Subject: [PATCH 27/30] Add Stacks Explorer link to successful reclaim transaction state --- .../components/voting/ReclaimVoteAction.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 1ed3fca6..6b9e04e4 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -5,7 +5,7 @@ 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 } from 'lucide-react'; +import { Coins, Lock, CheckCircle, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'; /** * Props for the ReclaimVoteAction component. @@ -39,6 +39,7 @@ export default function ReclaimVoteAction({ execute, isLoading: isReclaiming, isSuccess, + txId, error, reset: resetTransaction } = useTransaction({ @@ -164,9 +165,21 @@ export default function ReclaimVoteAction({
-

- Stake reclaimed successfully! Your available balance has been updated. -

+
+

+ Stake reclaimed successfully! Your available balance has been updated. +

+ {txId && ( + + View Transaction + + )} +
)} From 8e43cf726dbeb356748dee5c5909e9de43cdc409 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:43:42 +0100 Subject: [PATCH 28/30] Add unit test for explorer link in successful ReclaimVoteAction state --- .../voting/ReclaimVoteAction.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/components/voting/ReclaimVoteAction.test.tsx b/frontend/components/voting/ReclaimVoteAction.test.tsx index 6ee70ff0..fe0a9684 100644 --- a/frontend/components/voting/ReclaimVoteAction.test.tsx +++ b/frontend/components/voting/ReclaimVoteAction.test.tsx @@ -114,6 +114,28 @@ describe('ReclaimVoteAction', () => { 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() }); From bd5b8918a69a549c0734093f460444c7cc842fc3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:49:59 +0100 Subject: [PATCH 29/30] Enhance JSDoc documentation for voting record cache in blockchain-cache.ts --- frontend/src/lib/blockchain-cache.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/blockchain-cache.ts b/frontend/src/lib/blockchain-cache.ts index 6aa5383a..11766c93 100644 --- a/frontend/src/lib/blockchain-cache.ts +++ b/frontend/src/lib/blockchain-cache.ts @@ -140,6 +140,13 @@ 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}`); } From 2b43376a80e09121e9b50d6acd7a1cec46d05bff Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 24 Apr 2026 12:50:27 +0100 Subject: [PATCH 30/30] Complete Vote-Cost Reclaim Flow implementation with 30 granular commits