"use client"
import { AbstractVotingButton } from "@/components/abstract-voting-button"
const onchainHeroesData = {
id: "25",
name: "Onchain Heroes",
description: "The idle RPG for degens. Onchain Heroes is an idle RPG with a focus on onchain financial mechanics. Low time requirement, high thought requirement. The entire game—moves, items, and progress—exists on the blockchain, ensuring that it is verifiable by anyone.",
icon: "https://abstract-portal-metadata-prod.s3.amazonaws.com/76c5ffe6-124f-43e7-97cf-f85a36f1bb4f.png",
banner: "https://abstract-portal-metadata-prod.s3.amazonaws.com/c6497be9-5786-4f0e-a0e2-c4302a2fcd0f.png",
votes: 2578,
link: "https://play.onchainheroes.xyz/",
spotlight: "global",
category: "Gaming",
twitter: "https://x.com/onchainheroes"
}
function VotingCardDemo() {
return (
<div className="relative w-full">
{/* Card container with blurred background */}
<div className="relative overflow-hidden rounded-xl border shadow-2xl hover:shadow-3xl transition-all duration-500 group">
{/* Blurred background image */}
<div className="absolute inset-0">
<img
src={onchainHeroesData.banner}
alt={`${onchainHeroesData.name} background`}
className="w-full h-full object-cover scale-110 blur-lg opacity-40"
/>
{/* Theme-aware overlay for better contrast */}
<div className="absolute inset-0 bg-white/80 dark:bg-black/60" />
</div>
{/* Main banner section */}
<div className="relative h-48 overflow-hidden">
<img
src={onchainHeroesData.banner}
alt={`${onchainHeroesData.name} banner`}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/20" />
</div>
{/* App icon, name, and vote button section */}
<div className="relative p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
{/* Top row: App icon and name */}
<div className="flex items-center gap-4 flex-1">
{/* App icon */}
<div className="w-12 h-12 sm:w-16 sm:h-16 rounded-xl border-3 border-gray-200/50 dark:border-white/20 shadow-xl overflow-hidden bg-white/30 dark:bg-white/10 backdrop-blur-sm">
<img
src={onchainHeroesData.icon}
alt={`${onchainHeroesData.name} icon`}
className="w-full h-full object-cover"
/>
</div>
{/* App name */}
<div className="flex-1">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white drop-shadow-lg">
{onchainHeroesData.name}
</h3>
</div>
{/* Vote button - hidden on mobile, shown on desktop */}
<div className="hidden sm:block">
<AbstractVotingButton
appId={onchainHeroesData.id}
className="text-sm whitespace-nowrap min-w-[200px]"
/>
</div>
</div>
{/* Bottom row: Vote button on mobile */}
<div className="block sm:hidden">
<AbstractVotingButton
appId={onchainHeroesData.id}
className="w-full"
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default function AbstractAppVotingDemo() {
return (
<VotingCardDemo />
)
}
Ensure you have installed and setup the AGW Provider wrapper component first.
pnpm dlx shadcn@latest add "https://build.abs.xyz/r/abstract-app-voting.json"
Note: Voting is only available on Abstract Mainnet. The component will throw an error if used on other networks.
"use client"
import { AbstractVotingButton } from "@/components/abstract-voting-button"
export default function VotingExample() {
return (
<AbstractVotingButton appId="1" />
)
}
"use client"
import { AbstractVotingButton } from "@/components/abstract-voting-button"
import { toast } from "sonner"
export default function VotingWithToast() {
return (
<AbstractVotingButton
appId="1"
onVoteSuccess={() => {
toast.success("Vote Submitted!", {
description: "Your vote has been recorded.",
})
}}
onVoteError={(error) => {
toast.error("Vote Failed", {
description: error.message,
})
}}
/>
)
}
"use client"
import { AbstractVotingButton } from "@/components/abstract-voting-button"
export default function CustomContentVoting() {
return (
<AbstractVotingButton appId="1">
🚀 Vote for this App
</AbstractVotingButton>
)
}
"use client";
import { useAccount } from "wagmi";
import { Button } from "@/components/ui/button";
import { useUserVoteStatus } from "@/hooks/use-user-vote-status";
import { useVoteForApp } from "@/hooks/use-vote-for-app";
const APP_ID = "15";
export default function CustomVotingImplementation() {
const { isConnected } = useAccount();
const { hasVoted, isLoading: statusLoading } = useUserVoteStatus({
appId: APP_ID,
enabled: isConnected,
});
const { voteForApp, isLoading: voteLoading } = useVoteForApp({
onSuccess: (hash) => console.log("Vote submitted!", hash),
onError: (error) => console.error("Vote failed:", error),
});
const isLoading = statusLoading || voteLoading;
return (
<Button onClick={() => voteForApp(APP_ID)} disabled={isLoading || hasVoted}>
{isLoading ? "Loading..." : hasVoted ? "Voted" : "Vote"}
</Button>
);
}
This command installs the following files:
/components
abstract-voting-button.tsx
- Main voting button component/hooks
use-user-vote-status.ts
- Hook to check if user has voteduse-vote-for-app.ts
- Hook to submit votes/lib
voting-contract.ts
- Contract address and ABIvoting-utils.ts
- Utility functionsabstract-voting-button.tsx
A comprehensive voting button component with automatic wallet connection, vote status checking, and transaction submission support.
"use client";
import { useLoginWithAbstract } from "@abstract-foundation/agw-react";
import { Button } from "@/components/ui/button";
import { useAccount } from "wagmi";
import { cn } from "@/lib/utils";
import { type ClassValue } from "clsx";
import { toast } from "sonner";
import { abstract } from "viem/chains";
import { useUserVoteStatus } from "@/hooks/use-user-vote-status";
import { useVoteForApp } from "@/hooks/use-vote-for-app";
interface AbstractVotingButtonProps {
appId: string | number | bigint;
className?: ClassValue;
children?: React.ReactNode;
onVoteSuccess?: (data: `0x${string}`) => void;
onVoteError?: (error: Error) => void;
disabled?: boolean;
}
/**
* Abstract Voting Button
*
* A voting button component that handles:
* - Checking if user has already voted for an app
* - Wallet connection via Abstract Global Wallet
* - Submitting votes with proper loading states
* - Error handling and user feedback
*/
export function AbstractVotingButton({
appId,
className,
children,
onVoteSuccess,
onVoteError,
disabled = false,
}: AbstractVotingButtonProps) {
const { isConnected, chainId } = useAccount();
const { login } = useLoginWithAbstract();
// Check if user has already voted for this app
const {
hasVoted,
isLoading: isStatusLoading,
} = useUserVoteStatus({
appId,
enabled: isConnected,
});
const { voteForApp, isLoading: isVoteLoading } = useVoteForApp({
onSuccess: (data) => {
onVoteSuccess?.(data);
},
onError: (error) => {
onVoteError?.(error);
},
});
const isLoading = isStatusLoading || isVoteLoading;
// Handle vote submission
const handleVote = async () => {
if (!isConnected) {
login();
return;
}
// Check if user is on testnet and show error toast
if (chainId && chainId !== abstract.id) {
toast.error(
"App voting is only supported on Abstract mainnet. Please switch networks to vote."
);
return;
}
if (hasVoted || disabled) {
return;
}
try {
await voteForApp(appId);
} catch (err) {
// Error handling is done in the hook
}
};
// Determine button text and state
const getButtonContent = () => {
if (children) {
return children;
}
if (!isConnected) {
return (
<>
Connect to Vote
<AbstractLogo className="ml-2" />
</>
);
}
if (isLoading) {
return (
<>
Loading...
<AbstractLogo className="ml-2 animate-spin" />
</>
);
}
if (hasVoted) {
return (
<>
Voted
<CheckIcon className="ml-2 h-4 w-4" />
</>
);
}
return (
<>
Upvote on Abstract
<VoteIcon className="ml-2 h-4 w-4" />
</>
);
};
const isButtonDisabled = disabled || isLoading || (isConnected && hasVoted);
return (
<Button
onClick={handleVote}
disabled={isButtonDisabled}
className={cn(
"relative transition-all duration-200",
hasVoted && "bg-green-600 hover:bg-green-700 text-white",
className
)}
>
{/* Reserve width for the longest default label to prevent layout shift */}
<span className="invisible whitespace-nowrap">
Upvote on Abstract <VoteIcon className="ml-1 h-4 w-4" />
</span>
{/* Center the active content over the reserved space */}
<span className="absolute inset-0 flex items-center justify-center">
{getButtonContent()}
</span>
</Button>
);
}
function VoteIcon({ className }: { className?: ClassValue }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
</svg>
);
}
function CheckIcon({ className }: { className?: ClassValue }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}
function AbstractLogo({ className }: { className?: ClassValue }) {
return (
<svg
width="20"
height="18"
viewBox="0 0 52 47"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(className)}
>
<path
d="M33.7221 31.0658L43.997 41.3463L39.1759 46.17L28.901 35.8895C28.0201 35.0081 26.8589 34.5273 25.6095 34.5273C24.3602 34.5273 23.199 35.0081 22.3181 35.8895L12.0432 46.17L7.22205 41.3463L17.4969 31.0658H33.7141H33.7221Z"
fill="currentColor"
/>
<path
d="M35.4359 28.101L49.4668 31.8591L51.2287 25.2645L37.1978 21.5065C35.9965 21.186 34.9954 20.4167 34.3708 19.335C33.7461 18.2613 33.586 17.0033 33.9063 15.8013L37.6623 1.76283L31.0713 0L27.3153 14.0385L35.4279 28.093L35.4359 28.101Z"
fill="currentColor"
/>
<path
d="M15.7912 28.101L1.76028 31.8591L-0.00158691 25.2645L14.0293 21.5065C15.2306 21.186 16.2316 20.4167 16.8563 19.335C17.4809 18.2613 17.6411 17.0033 17.3208 15.8013L13.5648 1.76283L20.1558 0L23.9118 14.0385L15.7992 28.093L15.7912 28.101Z"
fill="currentColor"
/>
</svg>
);
}
Prop | Type | Default | Description |
---|---|---|---|
appId | string | number | bigint | - | Required. The ID of the app to vote for |
className | ClassValue | undefined | Custom CSS classes |
children | React.ReactNode | undefined | Custom button content |
onVoteSuccess | (data: any) => void | undefined | Callback when vote succeeds |
onVoteError | (error: Error) => void | undefined | Callback when vote fails |
disabled | boolean | false | Disable the button |
use-user-vote-status.ts
Hook that checks if the current user has already voted for a specific app in the current epoch.
"use client";
import { useAccount, useReadContract } from "wagmi";
import {
ABSTRACT_VOTING_ADDRESS,
ABSTRACT_VOTING_ABI,
} from "../lib/voting-contract";
import {
hasUserVotedForApp,
formatAppId,
isValidAppId,
} from "../lib/voting-utils";
interface UseUserVoteStatusProps {
appId: string | number | bigint;
enabled?: boolean;
}
interface UserVoteStatus {
hasVoted: boolean;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook to check if the current user has voted for a specific app in the current epoch
*/
export function useUserVoteStatus({
appId,
enabled = true,
}: UseUserVoteStatusProps): UserVoteStatus {
const { address, isConnected } = useAccount();
// Get current epoch
const { data: currentEpoch, isLoading: isEpochLoading } = useReadContract({
address: ABSTRACT_VOTING_ADDRESS,
abi: ABSTRACT_VOTING_ABI,
functionName: "currentEpoch",
query: {
enabled: enabled && isConnected,
},
});
// Get user votes for current epoch
const {
data: userVotes,
isLoading: isVotesLoading,
error,
refetch,
} = useReadContract({
address: ABSTRACT_VOTING_ADDRESS,
abi: ABSTRACT_VOTING_ABI,
functionName: "getUserVotes",
args: address && currentEpoch ? [address, currentEpoch] : undefined,
query: {
enabled:
enabled &&
isConnected &&
!!address &&
!!currentEpoch &&
isValidAppId(appId),
},
});
// Check if user has voted for this specific app
const hasVoted = userVotes
? hasUserVotedForApp(userVotes, formatAppId(appId))
: false;
return {
hasVoted,
isLoading: isEpochLoading || isVotesLoading,
error: error as Error | null,
refetch,
};
}
Parameter | Type | Default | Description |
---|---|---|---|
appId | string | number | bigint | - | Required. The app ID to check |
enabled | boolean | true | Whether to enable the query |
use-vote-for-app.ts
Hook that handles submitting votes for apps using Abstract Global Wallet.
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAccount, useReadContract } from "wagmi"
import { useAbstractClient } from "@abstract-foundation/agw-react"
import { publicClient } from "@/config/viem-clients"
import { ABSTRACT_VOTING_ADDRESS, ABSTRACT_VOTING_ABI } from "../lib/voting-contract"
import { formatAppId, isValidAppId } from "../lib/voting-utils"
interface UseVoteForAppProps {
onSuccess?: (data: `0x${string}`) => void
onError?: (error: Error) => void
}
interface VoteForAppResult {
voteForApp: (appId: string | number | bigint) => Promise<`0x${string}`>
isLoading: boolean
error: Error | null
data: `0x${string}` | undefined
reset: () => void
}
/**
* Hook to submit a vote for an app using Abstract Global Wallet
*/
export function useVoteForApp({ onSuccess, onError }: UseVoteForAppProps = {}): VoteForAppResult {
const { isConnected, address } = useAccount()
const { data: abstractClient } = useAbstractClient()
const queryClient = useQueryClient()
// Get the current epoch to build the proper query key
const { data: currentEpoch } = useReadContract({
address: ABSTRACT_VOTING_ADDRESS,
abi: ABSTRACT_VOTING_ABI,
functionName: "currentEpoch",
query: { enabled: false }, // Don't fetch, just get the query key structure
})
// Get the getUserVotes query key for invalidation
const { queryKey } = useReadContract({
address: ABSTRACT_VOTING_ADDRESS,
abi: ABSTRACT_VOTING_ABI,
functionName: "getUserVotes",
args: address && currentEpoch ? [address, currentEpoch] : undefined,
query: { enabled: false }, // Don't fetch, just get the query key
})
// Create mutation for voting
const mutation = useMutation({
mutationFn: async (appId: string | number | bigint) => {
if (!isConnected) {
throw new Error("Wallet not connected")
}
if (!abstractClient) {
throw new Error("Abstract client not available")
}
if (!isValidAppId(appId)) {
throw new Error(`Invalid app ID for voting. App ID: ${appId}`)
}
const formattedAppId = formatAppId(appId)
// Submit the vote transaction using Abstract client
const hash = await abstractClient.writeContract({
address: ABSTRACT_VOTING_ADDRESS,
abi: ABSTRACT_VOTING_ABI,
functionName: "voteForApp",
args: [formattedAppId],
})
// Wait for transaction to be confirmed on-chain
await publicClient.waitForTransactionReceipt({ hash })
return hash
},
onSuccess: async (data) => {
// Invalidate getUserVotes queries using the proper wagmi query key
await queryClient.invalidateQueries({ queryKey })
onSuccess?.(data)
},
onError: (error: Error) => {
onError?.(error)
},
})
return {
voteForApp: mutation.mutateAsync,
isLoading: mutation.isPending,
error: mutation.error as Error | null,
data: mutation.data,
reset: mutation.reset,
}
}
Parameter | Type | Default | Description |
---|---|---|---|
onSuccess | (data: any) => void | undefined | Callback when vote succeeds |
onError | (error: Error) => void | undefined | Callback when vote fails |
Property | Type | Description |
---|---|---|
voteForApp | (appId: string | number | bigint) => Promise<void> | Function to submit a vote |
isLoading | boolean | Whether a vote is being submitted |
error | Error | null | Any error that occurred |
data | any | Transaction data from successful vote |
reset | () => void | Reset the mutation state |
voting-contract.ts
Contains the Abstract Voting contract address and ABI for both mainnet and testnet.
export const ABSTRACT_VOTING_ADDRESS = "0x3b50de27506f0a8c1f4122a1e6f470009a76ce2a" as const
export const ABSTRACT_VOTING_ABI = [{ "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }, { "internalType": "contract IAppRegistry", "name": "_appRegistry", "type": "address" }, { "internalType": "contract IVoteGovernor", "name": "_voteGovernor", "type": "address" }], "stateMutability": "nonpayable", "type": "constructor" }, { "inputs": [], "name": "AlreadyInitialized", "type": "error" }, { "inputs": [{ "internalType": "uint256", "name": "appId", "type": "uint256" }], "name": "AlreadyVotedFor", "type": "error" }, { "inputs": [], "name": "AppNotActive", "type": "error" }, { "inputs": [], "name": "IndexOutOfBounds", "type": "error" }, { "inputs": [], "name": "InvalidSchedule", "type": "error" }, { "inputs": [], "name": "InvalidValue", "type": "error" }, { "inputs": [], "name": "NewOwnerIsZeroAddress", "type": "error" }, { "inputs": [], "name": "NoHandoverRequest", "type": "error" }, { "inputs": [], "name": "Unauthorized", "type": "error" }, { "inputs": [], "name": "UsedAllVotes", "type": "error" }, { "inputs": [], "name": "VotingNotActive", "type": "error" }, { "inputs": [], "name": "WithdrawFailed", "type": "error" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "newAppRegistry", "type": "address" }], "name": "AppRegistryUpdated", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "pendingOwner", "type": "address" }], "name": "OwnershipHandoverCanceled", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "pendingOwner", "type": "address" }], "name": "OwnershipHandoverRequested", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "oldOwner", "type": "address" }, { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" }], "name": "OwnershipTransferred", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "user", "type": "address" }, { "indexed": true, "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "RolesUpdated", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": false, "internalType": "uint256", "name": "startTime", "type": "uint256" }, { "indexed": false, "internalType": "uint256", "name": "epochDuration", "type": "uint256" }, { "indexed": false, "internalType": "uint256", "name": "epochsCompleted", "type": "uint256" }], "name": "ScheduleInitialized", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "newGovernor", "type": "address" }], "name": "VoteGovernorUpdated", "type": "event" }, { "anonymous": false, "inputs": [{ "indexed": true, "internalType": "address", "name": "voter", "type": "address" }, { "indexed": true, "internalType": "uint256", "name": "appId", "type": "uint256" }, { "indexed": true, "internalType": "uint256", "name": "epoch", "type": "uint256" }], "name": "Voted", "type": "event" }, { "inputs": [], "name": "MANAGER_ROLE", "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "appRegistry", "outputs": [{ "internalType": "contract IAppRegistry", "name": "", "type": "address" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "cancelOwnershipHandover", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "pendingOwner", "type": "address" }], "name": "completeOwnershipHandover", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [], "name": "currentEpoch", "outputs": [{ "internalType": "uint256", "name": "epoch", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "currentSchedule", "outputs": [{ "internalType": "uint40", "name": "startTime", "type": "uint40" }, { "internalType": "uint40", "name": "epochDuration", "type": "uint40" }, { "internalType": "uint40", "name": "epochsCompleted", "type": "uint40" }, { "internalType": "uint96", "name": "voteCost", "type": "uint96" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }, { "internalType": "uint256", "name": "epoch", "type": "uint256" }], "name": "getUserVotes", "outputs": [{ "internalType": "uint256[]", "name": "", "type": "uint256[]" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "uint256", "name": "appId", "type": "uint256" }, { "internalType": "uint256", "name": "epoch", "type": "uint256" }], "name": "getVotesForApp", "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }, { "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "grantRoles", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }, { "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "hasAllRoles", "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }, { "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "hasAnyRole", "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "uint40", "name": "_startTime", "type": "uint40" }, { "internalType": "uint40", "name": "_epochDuration", "type": "uint40" }, { "internalType": "uint96", "name": "_voteCost", "type": "uint96" }], "name": "initializeSchedule", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "nextSchedule", "outputs": [{ "internalType": "uint40", "name": "startTime", "type": "uint40" }, { "internalType": "uint40", "name": "epochDuration", "type": "uint40" }, { "internalType": "uint40", "name": "epochsCompleted", "type": "uint40" }, { "internalType": "uint96", "name": "voteCost", "type": "uint96" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "owner", "outputs": [{ "internalType": "address", "name": "result", "type": "address" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "pendingOwner", "type": "address" }], "name": "ownershipHandoverExpiresAt", "outputs": [{ "internalType": "uint256", "name": "result", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "renounceOwnership", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "renounceRoles", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [], "name": "requestOwnershipHandover", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }, { "internalType": "uint256", "name": "roles", "type": "uint256" }], "name": "revokeRoles", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }], "name": "rolesOf", "outputs": [{ "internalType": "uint256", "name": "roles", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "newAppRegistry", "type": "address" }], "name": "setAppRegistry", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "newGovernor", "type": "address" }], "name": "setVoteGovernor", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], "name": "transferOwnership", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }], "name": "userVoteSpend", "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "address", "name": "user", "type": "address" }], "name": "userVotesRemaining", "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "voteCost", "outputs": [{ "internalType": "uint96", "name": "", "type": "uint96" }], "stateMutability": "view", "type": "function" }, { "inputs": [{ "internalType": "uint256", "name": "appId", "type": "uint256" }], "name": "voteForApp", "outputs": [], "stateMutability": "payable", "type": "function" }, { "inputs": [], "name": "voteGovernor", "outputs": [{ "internalType": "contract IVoteGovernor", "name": "", "type": "address" }], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function" }] as const
voting-utils.ts
Utility functions for validating app IDs and checking vote status.
import { ABSTRACT_VOTING_ADDRESS, ABSTRACT_VOTING_ABI } from "./voting-contract"
/**
* Custom error class for Abstract voting configuration issues.
* These errors should bubble up to show helpful messages to developers.
*/
export class VotingConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = "VotingConfigurationError";
}
}
/**
* Check if a user has voted for a specific app in the current epoch
*/
export function hasUserVotedForApp(userVotes: readonly bigint[], appId: bigint): boolean {
return userVotes.some(vote => vote === appId)
}
/**
* Format app ID to ensure it's a valid bigint
*/
export function formatAppId(appId: string | number | bigint): bigint {
return BigInt(appId)
}
/**
* Validate that an app ID is a positive number
*/
export function isValidAppId(appId: string | number | bigint): boolean {
try {
const id = BigInt(appId)
return id > BigInt(0)
} catch {
return false
}
}
export { ABSTRACT_VOTING_ADDRESS, ABSTRACT_VOTING_ABI }