diff --git a/contracts/sprintfund-core-v3.clar b/contracts/sprintfund-core-v3.clar index 53b3259a..7b4916ab 100644 --- a/contracts/sprintfund-core-v3.clar +++ b/contracts/sprintfund-core-v3.clar @@ -107,6 +107,12 @@ (ok (var-get contract-owner)) ) +;; Get the contract version +;; @returns: Contract version number +(define-read-only (get-version) + (ok u3) +) + ;; ============================================ ;; Initialization ;; ============================================ diff --git a/frontend/components/CreateProposalForm.tsx b/frontend/components/CreateProposalForm.tsx index 57d013dc..d08444e8 100644 --- a/frontend/components/CreateProposalForm.tsx +++ b/frontend/components/CreateProposalForm.tsx @@ -16,6 +16,7 @@ import CategoryTags from './common/CategoryTags'; import { predictProposalSuccess } from '@/utils/successPredictor'; import { Target, AlertCircle, Sparkles, Brain, CheckCircle2 } from 'lucide-react'; import { useTransaction } from '@/hooks/useTransaction'; +import { ContractVersionGuard } from './common/ContractVersionGuard'; const NETWORK = STACKS_MAINNET; @@ -150,6 +151,8 @@ export default function CreateProposalForm({ userAddress }: CreateProposalFormPr + +
{/* Title Field */}
@@ -296,6 +299,7 @@ export default function CreateProposalForm({ userAddress }: CreateProposalFormPr )} + {/* Success Forecast Sidebar */} diff --git a/frontend/components/ExecuteProposal.tsx b/frontend/components/ExecuteProposal.tsx index 62e30eea..d373b8db 100644 --- a/frontend/components/ExecuteProposal.tsx +++ b/frontend/components/ExecuteProposal.tsx @@ -10,6 +10,7 @@ import { STACKS_MAINNET } from '@stacks/network'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '@/config'; import { useTransaction } from '@/hooks/useTransaction'; import { useCurrentBlockHeight } from '@/hooks'; +import { ContractVersionGuard } from './common/ContractVersionGuard'; const NETWORK = STACKS_MAINNET; @@ -111,53 +112,55 @@ export default function ExecuteProposal({ return (
-
-
- - - -
-

- This proposal has passed! You can now execute it. -

-

- Votes: {votesFor} for, {votesAgainst} against -

+ +
+
+ + + +
+

+ This proposal has passed! You can now execute it. +

+

+ Votes: {votesFor} for, {votesAgainst} against +

+
-
- + + {/* Success Message */} + {success && ( +
+

{success}

+
)} - - - {/* Success Message */} - {success && ( -
-

{success}

-
- )} - {/* Error Message */} - {error && ( -
-

{error}

-
- )} -
+ {/* Error Message */} + {error && ( +
+

{error}

+
+ )} +
+
); } diff --git a/frontend/components/ProposalList.tsx b/frontend/components/ProposalList.tsx index 20060c46..dead5848 100644 --- a/frontend/components/ProposalList.tsx +++ b/frontend/components/ProposalList.tsx @@ -20,6 +20,7 @@ import { PaginationControls } from './PaginationControls'; import { useNextProposalFilters } from '../hooks/useNextProposalFilters'; import { useTransaction } from '@/hooks/useTransaction'; import { useRefreshOnConfirmation } from '@/hooks/useRefreshOnConfirmation'; +import { ContractVersionGuard } from './common/ContractVersionGuard'; import { paginateProposals } from '@/lib/proposal-utils'; import { Proposal } from '@/types'; import { normalizeError } from '@/lib/error-normalizer'; @@ -220,169 +221,171 @@ export default function ProposalList({ userAddress }: ProposalListProps) { return (
-
-
Cast Your Vote
-
- -
- Quadratic voting uses cost = weight^2. A larger vote carries higher impact but each additional unit of weight costs more stake than the previous one. + +
+
Cast Your Vote
+
+ +
+ Quadratic voting uses cost = weight^2. A larger vote carries higher impact but each additional unit of weight costs more stake than the previous one. +
-
-
-
- Quadratic cost curve - cost = weight^2 -
-
- - `${point.x},${point.y}`).join(' ')} - /> - {selectedPoint && ( - - )} - -
-
- Weight 1 - Weight {curveMaxWeight} +
+
+ Quadratic cost curve + cost = weight^2 +
+
+ + `${point.x},${point.y}`).join(' ')} + /> + {selectedPoint && ( + + )} + +
+
+ Weight 1 + Weight {curveMaxWeight} +
-
-
-
- - {weight || 1} -
- 0 ? weight : 1} - onChange={(e) => handleWeightInput(e.target.value)} - className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-white/20" - disabled={isVoting || sliderMax <= 1} - /> - handleWeightInput(e.target.value)} - min="1" - max={sliderMax} - placeholder="10" - className="mt-2 w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-sm text-white placeholder-purple-300/50 focus:outline-none focus:ring-2 focus:ring-purple-400" - disabled={isVoting} - /> +
+
+ + {weight || 1} +
+ 0 ? weight : 1} + onChange={(e) => handleWeightInput(e.target.value)} + className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-white/20" + disabled={isVoting || sliderMax <= 1} + /> + handleWeightInput(e.target.value)} + min="1" + max={sliderMax} + placeholder="10" + className="mt-2 w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-sm text-white placeholder-purple-300/50 focus:outline-none focus:ring-2 focus:ring-purple-400" + disabled={isVoting} + /> + + {stakeLoading && !!userAddress && ( +

Loading stake balance...

+ )} - {stakeLoading && !!userAddress && ( -

Loading stake balance...

- )} + {weight > 0 && ( +
+
+ Quadratic Cost + {cost.toLocaleString()} units +
+
+ Cost in STX + {formatSTX(cost, 6)} STX +
+ {hasStakeData && ( + <> +
+ Your Stake + {formatSTX(stakeAmount)} STX +
+
+ Remaining After Vote + {formatSTX(remainingStake)} STX +
+ + )} +
+ )} - {weight > 0 && ( -
-
- Quadratic Cost - {cost.toLocaleString()} units + {weight > 0 && ( +
+
+ Voting power per STX spent + {votingPowerPerStx.toFixed(4)} votes / unit +
+

+ Efficiency drop vs weight 1: {efficiencyDrop.toFixed(2)}% +

-
- Cost in STX - {formatSTX(cost, 6)} STX + )} + + {isNearLimit && ( +
+

Warning: this vote uses more than 80% of your current staked balance.

- {hasStakeData && ( - <> -
- Your Stake - {formatSTX(stakeAmount)} STX -
-
- Remaining After Vote - {formatSTX(remainingStake)} STX -
- - )} -
- )} + )} - {weight > 0 && ( -
-
- Voting power per STX spent - {votingPowerPerStx.toFixed(4)} votes / unit + {exceedsStake && ( +
+

This vote cost exceeds your staked balance. Reduce the vote weight.

-

- Efficiency drop vs weight 1: {efficiencyDrop.toFixed(2)}% -

-
- )} + )} +
+ +
+ + +
- {isNearLimit && ( -
-

Warning: this vote uses more than 80% of your current staked balance.

+ {/* Success Message */} + {voteSuccess && ( +
+

{voteSuccess}

)} - {exceedsStake && ( -
-

This vote cost exceeds your staked balance. Reduce the vote weight.

+ {/* Error Message */} + {voteError && ( +
+

{voteError}

)} -
- -
- - -
- - {/* Success Message */} - {voteSuccess && ( -
-

{voteSuccess}

-
- )} - - {/* Error Message */} - {voteError && ( -
-

{voteError}

-
- )} +
); }; diff --git a/frontend/components/voting/ReclaimVoteAction.tsx b/frontend/components/voting/ReclaimVoteAction.tsx index 260a3eab..2e9fd5c6 100644 --- a/frontend/components/voting/ReclaimVoteAction.tsx +++ b/frontend/components/voting/ReclaimVoteAction.tsx @@ -6,6 +6,7 @@ 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'; +import { ContractVersionGuard } from '../common/ContractVersionGuard'; /** * Props for the ReclaimVoteAction component. @@ -97,69 +98,71 @@ export default function ReclaimVoteAction({ return (
-
-
- {isVotingActive ? ( - - ) : ( - - )} -
- -
-
-

- Vote Cost Recovery -

- {!isVotingActive && ( - - - Available - + +
+
+ {isVotingActive ? ( + + ) : ( + )}
- -

- {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. +

+
+

+ 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 && (
diff --git a/frontend/src/components/common/ContractVersionGuard.tsx b/frontend/src/components/common/ContractVersionGuard.tsx new file mode 100644 index 00000000..4d7e4d31 --- /dev/null +++ b/frontend/src/components/common/ContractVersionGuard.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useContractVersion } from '../../hooks/useContractVersion'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +interface ContractVersionGuardProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function ContractVersionGuard({ children, fallback }: ContractVersionGuardProps) { + const { isSupported, loading, error, isValidated, version } = useContractVersion(); + + if (loading) { + return ( +
+ + Verifying contract version... +
+ ); + } + + if (!isValidated || !isSupported) { + if (fallback) return <>{fallback}; + + return ( +
+
+ +

Contract Version Mismatch

+
+

+ The app detected an unexpected contract version (v{version || 'unknown'}). + Actions are disabled to prevent transaction failures. +

+ {error && ( +
+ {error} +
+ )} +
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index b5ffba82..928c776c 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -4,6 +4,7 @@ export const NETWORK = (process.env.NEXT_PUBLIC_NETWORK || 'mainnet') as 'mainne export const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS || 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'; export const CONTRACT_NAME = process.env.NEXT_PUBLIC_CONTRACT_NAME || 'sprintfund-core-v3'; export const CONTRACT_PRINCIPAL = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; +export const EXPECTED_CONTRACT_VERSION = 3; /* ── API ──────────────────────────────────────── */ diff --git a/frontend/src/hooks/useContractVersion.ts b/frontend/src/hooks/useContractVersion.ts new file mode 100644 index 00000000..32144cf2 --- /dev/null +++ b/frontend/src/hooks/useContractVersion.ts @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import { contractVersionService, type ContractVersionInfo } from '../services/contract-version'; + +export function useContractVersion() { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + async function checkVersion() { + try { + const versionInfo = await contractVersionService.getVersionInfo(); + if (isMounted) { + setInfo(versionInfo); + } + } catch (error) { + if (isMounted) { + setInfo({ + version: 0, + isSupported: false, + isValidated: false, + error: 'Failed to initialize version check', + }); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + checkVersion(); + + return () => { + isMounted = false; + }; + }, []); + + return { + ...info, + loading, + isSupported: info?.isSupported ?? false, + isValidated: info?.isValidated ?? false, + }; +} diff --git a/frontend/src/services/contract-version.ts b/frontend/src/services/contract-version.ts new file mode 100644 index 00000000..bb465fb3 --- /dev/null +++ b/frontend/src/services/contract-version.ts @@ -0,0 +1,81 @@ +import { + callReadOnlyFunction, + cvToValue, + standardPrincipalCV, + uintCV +} from '@stacks/transactions'; +import { + API_URL, + CONTRACT_ADDRESS, + CONTRACT_NAME, + EXPECTED_CONTRACT_VERSION, + NETWORK +} from '../config'; + +export interface ContractVersionInfo { + version: number; + isSupported: boolean; + isValidated: boolean; + error?: string; +} + +class ContractVersionService { + private cache: ContractVersionInfo | null = null; + private pendingPromise: Promise | null = null; + + async getVersionInfo(forceRefresh = false): Promise { + if (this.cache && !forceRefresh) { + return this.cache; + } + + if (this.pendingPromise) { + return this.pendingPromise; + } + + this.pendingPromise = this.fetchVersion(); + try { + const info = await this.pendingPromise; + this.cache = info; + return info; + } finally { + this.pendingPromise = null; + } + } + + private async fetchVersion(): Promise { + try { + // Call get-version read-only function + const result = await callReadOnlyFunction({ + contractAddress: CONTRACT_ADDRESS, + contractName: CONTRACT_NAME, + functionName: 'get-version', + functionArgs: [], + network: NETWORK, + senderAddress: CONTRACT_ADDRESS, // Use contract address as sender for simplicity + }); + + const version = Number(cvToValue(result).value); + + return { + version, + isSupported: version === EXPECTED_CONTRACT_VERSION, + isValidated: true, + }; + } catch (error: any) { + console.error('Failed to detect contract version:', error); + + return { + version: 0, + isSupported: false, + isValidated: false, + error: error.message || 'Unknown error during version detection', + }; + } + } + + clearCache() { + this.cache = null; + } +} + +export const contractVersionService = new ContractVersionService();