From 81966864b5507864b02cd8b5f023ca3d8c3a98c8 Mon Sep 17 00:00:00 2001 From: Clarvis Date: Sat, 7 Mar 2026 21:33:32 +0000 Subject: [PATCH] fix: verify governance voting power server-side instead of trusting client Previously, the governance vote endpoint accepted `votingPower` directly from the client POST body without server-side verification. This allowed any voter to claim arbitrary voting power (e.g., votingPower: 9999) when the system's design is 1 Star Skrumpey = 1 Vote. Changes: - Vote action now calls checkStarOwnershipBatched() to verify actual Star Skrumpey ownership on-chain before recording the vote - Voters with 0 Stars are rejected with a 403 error - Proposal creation now requires Star holder status (was wallet-auth only) - Removed unused client-supplied votingPower from destructuring Co-Authored-By: Claude Opus 4.6 --- app/api/governance/route.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/app/api/governance/route.ts b/app/api/governance/route.ts index 4bd8b54..3fa8acd 100644 --- a/app/api/governance/route.ts +++ b/app/api/governance/route.ts @@ -46,6 +46,7 @@ import { } from '@/lib/voteSignature'; import { verifyAdminAccess } from '@/lib/adminAuth'; import { verifyWalletAccess } from '@/lib/walletAuth'; +import { checkStarOwnershipBatched } from '@/lib/starSkrumpey'; /** * GET /api/governance @@ -331,6 +332,15 @@ export async function POST(request: NextRequest) { ); } + // Verify proposer holds at least one Star Skrumpey + const proposerStars = await checkStarOwnershipBatched(proposerAddress); + if (proposerStars.length === 0) { + return NextResponse.json( + { success: false, error: 'You must hold at least one Star Skrumpey to create proposals' }, + { status: 403 } + ); + } + // Validate voting duration (1-4 weeks) const duration = parseInt(votingDurationWeeks, 10) || 1; if (duration < 1 || duration > 4) { @@ -415,7 +425,7 @@ export async function POST(request: NextRequest) { // Cast a vote if (action === 'vote') { - const { proposalId, voterAddress, support, votingPower, reason, signature, nonce, signatureVersion, typedData } = body; + const { proposalId, voterAddress, support, reason, signature, nonce, signatureVersion } = body; // Strict input validation if (!proposalId || !voterAddress || support === undefined) { @@ -535,18 +545,35 @@ export async function POST(request: NextRequest) { } // Signature is valid, proceed with vote - logger.info('Governance: Valid signature verified', { - proposalId, + logger.info('Governance: Valid signature verified', { + proposalId, voterAddress: normalizedVoterAddress.slice(0, 10) + '...', version }); + // Server-side voting power verification: 1 Star Skrumpey = 1 Vote + // NEVER trust client-supplied votingPower + const voterStars = await checkStarOwnershipBatched(normalizedVoterAddress); + const verifiedVotingPower = voterStars.length; + + if (verifiedVotingPower === 0) { + return NextResponse.json( + { success: false, error: 'You must hold at least one Star Skrumpey to vote' }, + { status: 403 } + ); + } + + logger.info('Governance: Voting power verified on-chain', { + voterAddress: normalizedVoterAddress.slice(0, 10) + '...', + verifiedVotingPower, + }); + // Store vote with signature data const result = castGovernanceVote({ proposalId, voterAddress: normalizedVoterAddress, support: supportValue, - votingPower: parseInt(votingPower, 10) || 1, + votingPower: verifiedVotingPower, reason, signature, signatureVersion: version,