Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
67ba659
Initialize branch for vote-cost reclaim feature
Mosas2000 Apr 23, 2026
08e4248
Update contract types to include missing vote-cost fields and error c…
Mosas2000 Apr 23, 2026
5289967
Add costPaid field to VoteRecord type for reclaim support
Mosas2000 Apr 23, 2026
086ffdc
Update vote converter to map cost-paid field from contract data
Mosas2000 Apr 23, 2026
1369679
Extend blockchain cache to support vote record storage
Mosas2000 Apr 23, 2026
d03b433
Expose get-vote read-only helper and reclaim-vote-cost transaction in…
Mosas2000 Apr 23, 2026
1cb2282
Add reclaim-vote transaction type
Mosas2000 Apr 23, 2026
3e04791
Implement useVote hook for fetching on-chain voting records
Mosas2000 Apr 23, 2026
ab382e8
Export useVote hook from main index
Mosas2000 Apr 23, 2026
284bd01
Create ReclaimVoteAction component for vote-cost recovery flow
Mosas2000 Apr 23, 2026
0f5ab94
Integrate ReclaimVoteAction component into the ProposalDetailPage
Mosas2000 Apr 23, 2026
b7d40f4
Add unit tests for useVote hook
Mosas2000 Apr 24, 2026
9efc860
Add unit tests for ReclaimVoteAction component
Mosas2000 Apr 24, 2026
35dc385
Invalidate stake cache on successful reclaim to reflect updated avail…
Mosas2000 Apr 24, 2026
9b74f71
Update ReclaimVoteAction tests to mock invalidateStakeCache
Mosas2000 Apr 24, 2026
c3d9f86
Update vitest config to include tests in components and utils directo…
Mosas2000 Apr 24, 2026
e01b878
Fix test matching multiple elements for Available text
Mosas2000 Apr 24, 2026
77cf67f
Organize ReclaimVoteAction in voting directory and update barrel exports
Mosas2000 Apr 24, 2026
dda2406
Remove old component files after move
Mosas2000 Apr 24, 2026
98b6740
Enhance JSDoc documentation for useVote hook
Mosas2000 Apr 24, 2026
77a6323
Enhance JSDoc documentation for ReclaimVoteAction component
Mosas2000 Apr 24, 2026
680bfd9
Enhance JSDoc documentation for reclaim and vote API helpers in stack…
Mosas2000 Apr 24, 2026
0583d1f
Add success message and state feedback to ReclaimVoteAction component
Mosas2000 Apr 24, 2026
7633c29
Add error handling and retry UI to ReclaimVoteAction component
Mosas2000 Apr 24, 2026
a2294b2
Add loading skeleton state to ReclaimVoteAction component
Mosas2000 Apr 24, 2026
63df895
Add unit test for loading skeleton state in ReclaimVoteAction component
Mosas2000 Apr 24, 2026
5a8139d
Add Stacks Explorer link to successful reclaim transaction state
Mosas2000 Apr 24, 2026
8e43cf7
Add unit test for explorer link in successful ReclaimVoteAction state
Mosas2000 Apr 24, 2026
bd5b891
Enhance JSDoc documentation for voting record cache in blockchain-cac…
Mosas2000 Apr 24, 2026
2b43376
Complete Vote-Cost Reclaim Flow implementation with 30 granular commits
Mosas2000 Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions frontend/components/voting/ReclaimVoteAction.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ReclaimVoteAction proposal={mockProposal} />);
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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);
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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);
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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);

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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);

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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);

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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);

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(<ReclaimVoteAction proposal={mockProposal} userAddress="ST1234" />);

expect(screen.getByText(/Reclaiming STX.../i)).toBeTruthy();
const button = screen.getByRole('button') as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
});
207 changes: 207 additions & 0 deletions frontend/components/voting/ReclaimVoteAction.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-6 rounded-2xl bg-slate-900/50 border border-slate-800 p-6 backdrop-blur-sm shadow-xl animate-pulse">
<div className="flex items-start gap-5">
<div className="h-12 w-12 rounded-xl bg-slate-800" />
<div className="flex-1 space-y-3">
<div className="h-4 w-1/3 rounded bg-slate-800" />
<div className="h-4 w-full rounded bg-slate-800" />
<div className="h-10 w-full rounded bg-slate-800" />
</div>
</div>
</div>
);
}

// 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 (
<div className="mt-6 rounded-2xl bg-slate-900/50 border border-slate-800 p-6 backdrop-blur-sm shadow-xl transition-all hover:border-slate-700">
<div className="flex items-start gap-5">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shadow-lg ${
isVotingActive
? 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
: 'bg-green-500/10 text-green-400 border border-green-500/20'
}`}>
{isVotingActive ? (
<Lock className="h-6 w-6" />
) : (
<Coins className="h-6 w-6" />
)}
</div>

<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h4 className="text-lg font-bold text-white tracking-tight">
Vote Cost Recovery
</h4>
{!isVotingActive && (
<span className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-green-400 border border-green-500/20">
<CheckCircle className="h-3 w-3" />
Available
</span>
)}
</div>

<p className="text-sm text-slate-400 leading-relaxed mb-4">
{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.`
}
</p>

<button
onClick={handleReclaim}
disabled={!canReclaim || isReclaiming}
className={`group relative flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold transition-all ${
canReclaim && !isReclaiming
? 'bg-indigo-600 text-white shadow-[0_0_20px_rgba(79,70,229,0.3)] hover:bg-indigo-500 hover:-translate-y-0.5 active:translate-y-0'
: 'bg-slate-800 text-slate-500 cursor-not-allowed border border-slate-700'
}`}
>
{isReclaiming ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Reclaiming STX...
</>
) : (
<>
<Coins className="h-4 w-4 transition-transform group-hover:rotate-12" />
Reclaim {formatSTX(vote.costPaid)} STX
</>
)}
</button>

<div className="mt-4 flex items-start gap-3 rounded-lg bg-indigo-500/5 p-3 border border-indigo-500/10">
<AlertCircle className="h-4 w-4 text-indigo-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-indigo-200/70 font-medium italic leading-normal">
Note: Reclaiming vote cost does not remove your vote from the proposal tally. It simply releases the locked stake portion used for quadratic weight.
</p>
</div>
</div>
</div>

{isSuccess && (
<div className="mt-4 rounded-xl bg-green-500/10 border border-green-500/20 p-4 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-400" />
<div className="flex-1">
<p className="text-sm font-medium text-green-100">
Stake reclaimed successfully! Your available balance has been updated.
</p>
{txId && (
<a
href={`https://explorer.hiro.so/txid/${txId}?chain=mainnet`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 flex items-center gap-1 text-xs text-green-400 underline hover:text-green-300 font-bold"
>
View Transaction <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
</div>
)}

{error && (
<div className="mt-4 rounded-xl bg-red-500/10 border border-red-500/20 p-4 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-400" />
<div className="flex-1">
<p className="text-sm font-medium text-red-100">
Recovery failed: {error.message}
</p>
<button
onClick={resetTransaction}
className="mt-1 text-xs text-red-400 underline hover:text-red-300 font-bold"
>
Dismiss
</button>
</div>
</div>
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions frontend/components/voting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading