├── public
├── favicon.ico
├── images
│ ├── ballot-meta.png
│ └── logo
│ │ └── ballot.png
└── vercel.svg
├── next.config.js
├── pages
├── api
│ ├── hello.js
│ ├── auth
│ │ ├── middleware.js
│ │ ├── nonce.js
│ │ └── verify.js
│ ├── utils
│ │ └── cloudFlareKVClient.js
│ ├── storage
│ │ └── put.js
│ ├── polls
│ │ ├── update-description.js
│ │ └── archive.js
│ └── stacks
│ │ ├── pox-cycles
│ │ └── index.js
│ │ └── stx-balances
│ │ └── [pollId].js
├── all-polls.js
├── _app.js
├── summary
│ └── index.js
├── builder
│ └── [[...id]].js
└── [param].js
├── .gitignore
├── package.json
├── components
├── builder
│ └── Preview.component.js
├── home
│ ├── EcosystemLogos.module.css
│ ├── accordion.js
│ ├── WalletSelector.module.css
│ ├── EcosystemLogos.js
│ ├── HeroVisual.module.css
│ └── WalletSelector.js
├── common
│ ├── CountdownTimer.js
│ ├── ArchiveConfirmationModal.js
│ ├── MyVotesPopup.js
│ └── EditDescriptionModal.js
├── poll
│ ├── QRCodePopup.js
│ ├── PollService.js
│ └── ModernResultsVisualization.js
└── summary
│ ├── SummaryComponent.js
│ └── ChoosePollsPopup.js
├── LICENSE
├── clarity
├── nft.clar
└── ballot.clar
├── styles
├── Accordion.module.css
├── ChoosePollsPopup.module.css
├── quill-overrides.css
├── QRCodePopup.module.css
├── CountdownTimer.module.css
└── VotingMethodSelection.module.css
├── services
├── r2-storage.js
├── pox-validation.js
├── stx-dust-vote-utils.js
└── utils.js
├── README.md
├── CLAUDE.md
├── WARP.md
├── common
└── constants.js
└── utils
└── htmlSanitizer.js
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BlockSurvey/ballot/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/ballot-meta.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BlockSurvey/ballot/HEAD/public/images/ballot-meta.png
--------------------------------------------------------------------------------
/public/images/logo/ballot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BlockSurvey/ballot/HEAD/public/images/logo/ballot.png
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | }
6 |
7 | module.exports = nextConfig
8 |
--------------------------------------------------------------------------------
/pages/api/hello.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default function handler(req, res) {
4 | res.status(200).json({ name: 'John Doe' })
5 | }
6 |
--------------------------------------------------------------------------------
/pages/all-polls.js:
--------------------------------------------------------------------------------
1 | import { DashboardNavBarComponent } from "../components/common/DashboardNavBarComponent";
2 | import DashboardAllPollsComponent from "../components/dashboard/DashboardAllPollsComponent";
3 | import styles from "../styles/Dashboard.module.css";
4 |
5 | export default function Dashboard() {
6 | return (
7 | <>
8 | {/* Navigation */}
9 |
10 |
11 | {/* Main Dashboard Content */}
12 |
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 | .env.production
34 | .env.development
35 | .env*
36 |
37 | # vercel
38 | .vercel
39 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import { SSRProvider } from 'react-bootstrap'
3 | import '../styles/globals.css'
4 | import '../styles/quill-overrides.css'
5 |
6 | function MyApp({ Component, pageProps }) {
7 | return <>
8 |
9 | {/* Header */}
10 |
11 | {/* Fathom Analytics */}
12 |
16 |
17 |
18 | {/* Body */}
19 |
20 |
21 | >
22 | }
23 |
24 | export default MyApp
25 |
--------------------------------------------------------------------------------
/pages/api/auth/middleware.js:
--------------------------------------------------------------------------------
1 | import { getKV } from "../utils/cloudFlareKVClient";
2 |
3 | export async function validateApiKey(apiKey) {
4 | try {
5 | if (!apiKey) {
6 | throw new Error("API key is required");
7 | }
8 |
9 | // Remove 'Bearer ' prefix if present
10 | const cleanApiKey = apiKey.replace(/^Bearer\s+/, "");
11 |
12 | // Look up API key in KV storage
13 | const apiKeyData = await getKV(`apiKey::${cleanApiKey}`);
14 |
15 | if (!apiKeyData) {
16 | throw new Error("Invalid API key");
17 | }
18 |
19 | const keyData = JSON.parse(apiKeyData);
20 |
21 | return keyData;
22 | } catch (error) {
23 | throw new Error("Invalid API key");
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/pages/summary/index.js:
--------------------------------------------------------------------------------
1 | import { Col, Container, Row } from "react-bootstrap";
2 | import { DashboardNavBarComponent } from "../../components/common/DashboardNavBarComponent";
3 | import SummaryBuilderComponent from "../../components/summary/BuilderComponent";
4 |
5 | export default function SummaryBuilder() {
6 | // Design
7 | return (
8 | <>
9 | {/* Dashboard nav bar - Full width */}
10 |
11 |
12 | {/* Content container */}
13 |
14 |
15 |
16 | {/* Body */}
17 |
18 |
19 |
20 |
21 | >
22 | );
23 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ballot",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@aws-sdk/client-s3": "^3.896.0",
13 | "@stacks/common": "^6.16.0",
14 | "@stacks/connect": "^7.8.0",
15 | "@stacks/encryption": "^6.16.1",
16 | "@stacks/network": "^6.17.0",
17 | "@stacks/transactions": "^6.17.0",
18 | "axios": "^1.12.2",
19 | "bootstrap": "^5.2.0",
20 | "dompurify": "^3.2.7",
21 | "lodash": "^4.17.21",
22 | "nanoid": "^4.0.0",
23 | "next": "12.2.5",
24 | "qrcode.react": "^3.1.0",
25 | "quill": "^2.0.3",
26 | "react": "18.2.0",
27 | "react-bootstrap": "^2.5.0",
28 | "react-dom": "18.2.0",
29 | "react-quill": "^2.0.0",
30 | "uuid": "^8.3.2"
31 | },
32 | "devDependencies": {
33 | "eslint": "8.22.0",
34 | "eslint-config-next": "12.2.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/pages/builder/[[...id]].js:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { Col, Container, Row } from "react-bootstrap";
3 | import BuilderComponent from "../../components/builder/BuilderComponent";
4 | import { DashboardNavBarComponent } from "../../components/common/DashboardNavBarComponent";
5 |
6 | export default function Builder() {
7 | // Variables
8 | const router = useRouter();
9 | const pathParams = router.query.id;
10 |
11 | // Return
12 | return (
13 | <>
14 | {/* Dashboard nav bar - Full width */}
15 |
16 |
17 | {/* Content container */}
18 |
19 |
20 |
21 | {/* Body */}
22 |
23 |
24 |
25 |
26 | >
27 | );
28 | }
--------------------------------------------------------------------------------
/components/builder/Preview.component.js:
--------------------------------------------------------------------------------
1 | import { Modal } from "react-bootstrap";
2 | import PollComponent from "../poll/PollComponent";
3 |
4 | export default function PreviewComponent(props) {
5 | const { show, handleClose, pollObject, currentBitcoinBlockHeight, currentStacksBlockHeight } = props;
6 |
7 | return (
8 | <>
9 |
10 |
11 | Preview
12 |
13 |
14 |
21 |
22 |
23 | >
24 | )
25 | }
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 BlockSurvey
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/clarity/nft.clar:
--------------------------------------------------------------------------------
1 | ;; (impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
2 |
3 | (define-constant contract-owner tx-sender)
4 | (define-constant err-owner-only (err u100))
5 | (define-constant err-not-token-owner (err u101))
6 |
7 | (define-non-fungible-token stacksies uint)
8 |
9 | (define-data-var last-token-id uint u0)
10 |
11 | (define-read-only (get-last-token-id)
12 | (ok (var-get last-token-id))
13 | )
14 |
15 | (define-read-only (get-token-uri (token-id uint))
16 | (ok none)
17 | )
18 |
19 | (define-read-only (get-owner (token-id uint))
20 | (ok (nft-get-owner? stacksies token-id))
21 | )
22 |
23 | (define-public (transfer (token-id uint) (sender principal) (recipient principal))
24 | (begin
25 | (asserts! (is-eq tx-sender sender) err-not-token-owner)
26 | (nft-transfer? stacksies token-id sender recipient)
27 | )
28 | )
29 |
30 | (define-public (mint (recipient principal))
31 | (let
32 | (
33 | (token-id (+ (var-get last-token-id) u1))
34 | )
35 | (asserts! (is-eq tx-sender contract-owner) err-owner-only)
36 | (try! (nft-mint? stacksies token-id recipient))
37 | (var-set last-token-id token-id)
38 | (ok token-id)
39 | )
40 | )
--------------------------------------------------------------------------------
/styles/Accordion.module.css:
--------------------------------------------------------------------------------
1 | .items {
2 | cursor: pointer;
3 | }
4 |
5 | .faq_question {
6 | padding: 16px 0px;
7 | display: flex;
8 | justify-content: space-between;
9 | align-items: center;
10 | font-size: 20px;
11 | cursor: pointer;
12 | }
13 |
14 | .faq_answer {
15 | max-height: 0;
16 | overflow: hidden;
17 | opacity: 0;
18 | transition: all 0.3s cubic-bezier(0, 1, 0, 1);
19 | }
20 |
21 | .faq_answer_show {
22 | height: 100%;
23 | max-height: 9999px;
24 | opacity: 1;
25 | transition: all 0.3s cubic-bezier(1, 0, 1, 0);
26 | }
27 |
28 | .faq_answer_content {
29 | font-size: 18px;
30 | opacity: 0.9;
31 | padding-bottom: 16px;
32 | }
33 | .faq_answer_content a {
34 | color: black;
35 | }
36 |
37 | /* Extra small devices (portrait phones, less than 576px) */
38 | @media (max-width: 575.98px) {
39 | .accordion {
40 | margin-bottom: 60px;
41 | margin-top: 60px;
42 | }
43 |
44 | .faq_question {
45 | font-size: 18px;
46 | }
47 |
48 | .faq_answer_content {
49 | font-size: 16px;
50 | }
51 | }
52 |
53 | /* Small devices (landscape phones, 576px and up) */
54 | @media (min-width: 576px) and (max-width: 767.98px) {
55 | .accordion {
56 | margin-bottom: 60px;
57 | margin-top: 60px;
58 | }
59 |
60 | .faq_question {
61 | font-size: 18px;
62 | }
63 |
64 | .faq_answer_content {
65 | font-size: 16px;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/styles/ChoosePollsPopup.module.css:
--------------------------------------------------------------------------------
1 | /* Modal */
2 | .summary_modal_header_box {
3 | position: relative;
4 | padding: 20px;
5 | font-size: 18px;
6 | font-weight: 500;
7 | }
8 | .summary_modal_close_icon_btn_box {
9 | position: absolute;
10 | right: 20px;
11 | top: 20px;
12 | width: 24px;
13 | height: 24px;
14 | border-radius: 50%;
15 | border: none;
16 | display: flex;
17 | align-items: center;
18 | justify-content: center;
19 | transition: 0.3s all ease;
20 | }
21 | .summary_modal_body_box {
22 | padding: 0 20px 20px 20px;
23 | max-height: 75vh;
24 | overflow: auto;
25 | }
26 |
27 | .all_polls_list_outline_box {
28 | display: flex;
29 | align-items: flex-start;
30 | flex-direction: column;
31 | row-gap: 10px;
32 | }
33 | .all_polls_list_box {
34 | border-radius: 6px;
35 | padding: 16px;
36 | cursor: pointer;
37 | background-color: rgba(236, 239, 241, 0.3);
38 | width: 100%;
39 | transition: 0.2s ease all;
40 | }
41 | .all_polls_list_box:hover {
42 | background-color: rgba(236, 239, 241, 0.6);
43 | }
44 | .all_polls_status_box_active {
45 | padding: 1px 8px;
46 | background-color: #21b66f;
47 | font-size: 12px;
48 | color: white;
49 | border-radius: 24px;
50 | }
51 | .all_polls_status_box_draft {
52 | padding: 1px 8px;
53 | background-color: #fca43e;
54 | font-size: 12px;
55 | color: white;
56 | border-radius: 24px;
57 | }
58 | .all_polls_status_box_closed {
59 | padding: 1px 8px;
60 | background-color: #fa4949;
61 | font-size: 12px;
62 | color: white;
63 | border-radius: 24px;
64 | }
65 | .all_polls_description {
66 | font-size: 15px;
67 | color: rgba(0, 0, 0, 0.8);
68 | line-height: 1.6;
69 | }
70 |
--------------------------------------------------------------------------------
/pages/api/utils/cloudFlareKVClient.js:
--------------------------------------------------------------------------------
1 | // CloudFlare KV client
2 | const CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "";
3 | const NAMESPACE_ID = process.env.CLOUDFLARE_KV_NAMESPACE_ID || "";
4 | const API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
5 |
6 | const BASE_URL = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/storage/kv/namespaces/${NAMESPACE_ID}`;
7 |
8 | export async function putKV(key, value, options = {}) {
9 | const url = `${BASE_URL}/values/${encodeURIComponent(key)}`;
10 |
11 | const headers = {
12 | Authorization: `Bearer ${API_TOKEN}`,
13 | "Content-Type": "text/plain",
14 | };
15 |
16 | if (options.expirationTtl) {
17 | headers["expiration-ttl"] = options.expirationTtl;
18 | }
19 |
20 | const res = await fetch(url, {
21 | method: "PUT",
22 | headers,
23 | body: value,
24 | });
25 |
26 | if (!res.ok) {
27 | const error = await res.text();
28 | throw new Error(`Failed to write KV: ${error}`);
29 | }
30 |
31 | return true;
32 | }
33 |
34 | export async function getKV(key) {
35 | const url = `${BASE_URL}/values/${encodeURIComponent(key)}`;
36 |
37 | const res = await fetch(url, {
38 | headers: {
39 | Authorization: `Bearer ${API_TOKEN}`,
40 | },
41 | });
42 |
43 | if (!res.ok) {
44 | const error = await res.text();
45 | throw new Error(`Failed to read KV: ${error}`);
46 | }
47 |
48 | return await res.text();
49 | }
50 |
51 | export async function deleteKV(key) {
52 | const url = `${BASE_URL}/values/${encodeURIComponent(key)}`;
53 |
54 | const res = await fetch(url, {
55 | method: "DELETE",
56 | headers: {
57 | Authorization: `Bearer ${API_TOKEN}`,
58 | },
59 | });
60 |
61 | if (!res.ok) {
62 | const error = await res.text();
63 | throw new Error(`Failed to delete KV: ${error}`);
64 | }
65 |
66 | return true;
67 | }
68 |
--------------------------------------------------------------------------------
/styles/quill-overrides.css:
--------------------------------------------------------------------------------
1 | /* Quill Editor Focus Override Styles */
2 | /* This file ensures no blue focus outlines appear and no conflicting borders */
3 |
4 | /* Remove all default Quill borders - we handle them on the wrapper */
5 | .ql-snow {
6 | border: none !important;
7 | }
8 |
9 | .ql-snow .ql-toolbar {
10 | border: none !important;
11 | }
12 |
13 | .ql-snow .ql-container {
14 | border: none !important;
15 | }
16 |
17 | /* Remove default focus styles from Quill components */
18 | .ql-snow .ql-picker,
19 | .ql-snow .ql-picker-label,
20 | .ql-snow .ql-picker-options,
21 | .ql-snow .ql-picker-item,
22 | .ql-snow button,
23 | .ql-snow button:hover,
24 | .ql-snow button:focus,
25 | .ql-snow button:active,
26 | .ql-snow .ql-active,
27 | .ql-editor:focus,
28 | .ql-container:focus,
29 | .ql-toolbar:focus {
30 | outline: none !important;
31 | border: none !important;
32 | box-shadow: none !important;
33 | }
34 |
35 | /* Override any focus-visible styles */
36 | .ql-snow *:focus-visible {
37 | outline: none !important;
38 | box-shadow: none !important;
39 | }
40 |
41 | /* Let the component handle its own toolbar and container borders */
42 |
43 | /* Remove any webkit focus styles */
44 | .ql-snow *::-webkit-input-placeholder,
45 | .ql-snow *:-moz-placeholder,
46 | .ql-snow *::-moz-placeholder,
47 | .ql-snow *:-ms-input-placeholder {
48 | outline: none !important;
49 | }
50 |
51 | /* Ensure clean dropdown and tooltip styles */
52 | .ql-snow .ql-tooltip {
53 | border: 1px solid #e5e7eb !important;
54 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
55 | }
56 |
57 | .ql-snow .ql-picker.ql-expanded .ql-picker-options {
58 | border: 1px solid #e5e7eb !important;
59 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
60 | }
61 |
62 | /* Remove focus outline from toolbar elements only */
63 | .rich-text-editor .ql-toolbar *,
64 | .rich-text-editor .ql-toolbar *:focus,
65 | .rich-text-editor .ql-toolbar *:active,
66 | .rich-text-editor .ql-toolbar *:hover {
67 | outline: none !important;
68 | }
69 |
--------------------------------------------------------------------------------
/pages/api/auth/nonce.js:
--------------------------------------------------------------------------------
1 | import { randomBytes } from "crypto";
2 | import { putKV } from "../utils/cloudFlareKVClient";
3 | import { encryptECIES } from "@stacks/encryption";
4 | import { utf8ToBytes } from "@stacks/common";
5 |
6 | /**
7 | * API endpoint to generate a nonce for publicKey authentication
8 | * The nonce is used for signature verification in the verify endpoint
9 | */
10 | export default async function handler(req, res) {
11 | if (req.method !== "POST") {
12 | return res.status(405).json({ error: "Method not allowed" });
13 | }
14 |
15 | try {
16 | const { publicKey } = req.body;
17 |
18 | // Validate required fields
19 | if (!publicKey) {
20 | return res.status(400).json({ error: "publicKey is required" });
21 | }
22 |
23 | // Validate publicKey format (should be hex string)
24 | if (!/^[a-fA-F0-9]+$/.test(publicKey)) {
25 | return res.status(400).json({ error: "Invalid publicKey format" });
26 | }
27 |
28 | // Generate a random nonce (32 bytes = 256 bits)
29 | const nonce = randomBytes(32).toString("hex");
30 |
31 | // Create expiration time (5 minutes from now)
32 | const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
33 |
34 | // Store nonce in CloudFlare KV with publicKey as part of the key
35 | const nonceKey = `nonce::${publicKey}`;
36 | const nonceData = {
37 | nonce,
38 | publicKey,
39 | expiresAt,
40 | createdAt: Date.now(),
41 | };
42 |
43 | // Store the nonce in CloudFlare KV
44 | await putKV(nonceKey, JSON.stringify(nonceData), {
45 | expirationTtl: 300, // 5 minutes in seconds
46 | });
47 |
48 | // Encrypt the nonce using the public key
49 | const cipherObj = await encryptECIES(
50 | publicKey,
51 | utf8ToBytes(JSON.stringify(nonceData)),
52 | true
53 | );
54 |
55 | // Return nonce to client
56 | return res.status(200).json({
57 | data: {
58 | encryptedNonce: cipherObj,
59 | },
60 | });
61 | } catch (error) {
62 | console.error("Error generating nonce:", error);
63 | return res.status(500).json({
64 | error: "Failed to generate nonce",
65 | message: error.message,
66 | });
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/services/r2-storage.js:
--------------------------------------------------------------------------------
1 | import { Constants } from '../common/constants';
2 |
3 | // CloudFlare R2 configuration for client-side URL generation
4 | const R2_PUBLIC_URL = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL || process.env.CLOUDFLARE_R2_PUBLIC_URL || 'https://storage.ballot.gg';
5 |
6 | /**
7 | * Get network suffix based on STACKS_MAINNET_FLAG
8 | */
9 | const getNetworkSuffix = () => {
10 | return Constants.STACKS_MAINNET_FLAG ? 'mainnet' : 'testnet';
11 | };
12 |
13 | /**
14 | * Get the public URL for a PoxCycle mapping
15 | * @param {number} poxCycleNumber - The PoX cycle number
16 | * @param {string} network - Optional network override ('mainnet' or 'testnet')
17 | * @returns {string} - The public URL
18 | */
19 | export const getPoxCycleMappingUrl = (poxCycleNumber, network = null) => {
20 | const networkSuffix = network || getNetworkSuffix();
21 | return `${R2_PUBLIC_URL}/stacks/${networkSuffix}/pox-cycles/${poxCycleNumber}.json`;
22 | };
23 |
24 | /**
25 | * Get the public URL for STX balance snapshot by block height and poll ID
26 | * @param {string} pollId - The poll ID (contract deployer address)
27 | * @param {number} snapshotHeight - The snapshot block height
28 | * @param {string} network - Optional network override ('mainnet' or 'testnet')
29 | * @returns {string} - The public URL
30 | */
31 | export const getStxBalanceSnapshotUrl = (pollId, snapshotHeight, network = null) => {
32 | const networkSuffix = network || getNetworkSuffix();
33 | return `${R2_PUBLIC_URL}/stacks/${networkSuffix}/polls/${pollId}/stx-balance-by-snapshot-${snapshotHeight}.json`;
34 | };
35 |
36 | /**
37 | * Get the public URL for STX dust voting snapshot by block height and poll ID
38 | * @param {string} pollId - The poll ID (contract deployer address)
39 | * @param {number} snapshotHeight - The snapshot block height
40 | * @param {string} network - Optional network override ('mainnet' or 'testnet')
41 | * @returns {string} - The public URL
42 | */
43 | export const getStxDustVotingSnapshotUrl = (pollId, snapshotHeight, network = null) => {
44 | const networkSuffix = network || getNetworkSuffix();
45 | return `${R2_PUBLIC_URL}/stacks/${networkSuffix}/polls/${pollId}/stx-dust-voting-by-snapshot-${snapshotHeight}.json`;
46 | };
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Ballot.gg
2 |
3 | Decentralized Polls on Stacks
4 |
5 | ## FAQ
6 |
7 | 1. What is Ballot.gg?
8 | The Ballot is a decentralized polling app for DAO, NFT, DeFi, and Web 3 projects that puts community members at the center to come to a consensus on important decisions. Polls will be gated based on holdings of tokens, .BTC namespaces, and NFTs.
9 |
10 | 2. How does Ballot.gg help?
11 | Ballot.gg will help projects in the Stacks community to utilize tokens to govern decision-making on their platform. It will allow DAOs, NFTs, and DeFi's to get broad community consensus regarding proposed changes or ideas in a transparent and verifiable way.
12 |
13 | 3. How does Ballot.gg help Stacks community?
14 | Polling for consensus has been around for years and is today used in politics to make decisions (eg. Brexit in the UK). Ballot makes it easy to deploy or integrate a poll into your project. Stacks community members can create polls for almost anything they want to know as a collective. Ballot will open up Stacks community members to be actively engaged and get to know how other community members think about things.
15 |
16 | 4. Is Ballot.gg open source?
17 | Yes. The source of the smart contact is available here.
18 |
19 | 5. Is Ballot.gg free?
20 | Yes. There are no charges for creating polls in Ballot.
21 |
22 | 6. Who is the team behind Ballot.gg?
23 | We are developers from Team [BlockSurvey ↗](https://blocksurvey.io/?ref=ballot).
24 |
25 | ## Poll Results Auditor
26 |
27 | This script serves as the base for the ballot counting method used in Ballot.gg. The current implementation of Ballot.gg includes additional contract voting functionality that is not present in this auditor script. This script will be further used in the vote audit process to provide transparent and verifiable poll results.
28 |
29 | Script: [`scripts/poll-results-auditor.js`](https://github.com/BlockSurvey/ballot/blob/main/scripts/poll-results-auditor.js)
30 |
31 | ## Getting Started
32 |
33 | Ballot.gg is built using Next.js and interacts with Stacks Blockchain.
34 |
35 | Run the development server:
36 |
37 | ```bash
38 | npm run dev
39 | # or
40 | yarn dev
41 | ```
42 |
43 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
44 |
45 | ## License
46 |
47 | [MIT License](LICENSE)
48 |
--------------------------------------------------------------------------------
/components/home/EcosystemLogos.module.css:
--------------------------------------------------------------------------------
1 | .ecosystem_logos {
2 | margin-top: 48px;
3 | }
4 |
5 | .trustbar_text {
6 | display: block;
7 | font-size: 14px;
8 | color: #6b7280;
9 | margin-bottom: 20px;
10 | font-weight: 500;
11 | }
12 |
13 | .logo_grid {
14 | display: grid;
15 | grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
16 | gap: 16px;
17 | max-width: 100%;
18 | }
19 |
20 | .logo_item {
21 | display: flex;
22 | flex-direction: column;
23 | align-items: center;
24 | gap: 8px;
25 | padding: 12px 8px;
26 | border-radius: 12px;
27 | background: rgba(255, 255, 255, 0.8);
28 | border: 1px solid rgba(0, 0, 0, 0.08);
29 | transition: all 0.3s ease;
30 | cursor: pointer;
31 | }
32 |
33 | .logo_item:hover {
34 | background: rgba(255, 255, 255, 0.95);
35 | border-color: rgba(0, 0, 0, 0.15);
36 | transform: translateY(-2px);
37 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
38 | }
39 |
40 | .logo_item svg {
41 | width: 24px;
42 | height: 24px;
43 | transition: transform 0.2s ease;
44 | }
45 |
46 | .logo_item:hover svg {
47 | transform: scale(1.1);
48 | }
49 |
50 | .logo_item span {
51 | font-size: 12px;
52 | font-weight: 600;
53 | color: #374151;
54 | text-align: center;
55 | line-height: 1.2;
56 | }
57 |
58 | /* Responsive design */
59 | @media (max-width: 768px) {
60 | .ecosystem_logos {
61 | margin-top: 32px;
62 | }
63 |
64 | .logo_grid {
65 | grid-template-columns: repeat(4, 1fr);
66 | gap: 12px;
67 | }
68 |
69 | .logo_item {
70 | padding: 8px 6px;
71 | gap: 6px;
72 | }
73 |
74 | .logo_item svg {
75 | width: 20px;
76 | height: 20px;
77 | }
78 |
79 | .logo_item span {
80 | font-size: 10px;
81 | }
82 |
83 | .trustbar_text {
84 | font-size: 12px;
85 | text-align: center;
86 | margin-bottom: 16px;
87 | }
88 | }
89 |
90 | @media (max-width: 480px) {
91 | .logo_grid {
92 | grid-template-columns: repeat(3, 1fr);
93 | gap: 8px;
94 | }
95 |
96 | .logo_item {
97 | padding: 6px 4px;
98 | gap: 4px;
99 | }
100 |
101 | .logo_item svg {
102 | width: 18px;
103 | height: 18px;
104 | }
105 |
106 | .logo_item span {
107 | font-size: 9px;
108 | }
109 | }
--------------------------------------------------------------------------------
/components/home/accordion.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Constants } from "../../common/constants";
3 | import styles from "../../styles/Accordion.module.css";
4 |
5 | function AccordionFAQ() {
6 | const [selected, setSelected] = useState(null);
7 | const toggle = (i) => {
8 | if (selected == i) {
9 | return setSelected(null);
10 | }
11 | setSelected(i);
12 | };
13 |
14 | // Get faq's from constant.js
15 | const faq = Constants.FAQ;
16 |
17 | return (
18 |
19 | {faq.map((item, i) => (
20 |
21 |
toggle(i)}>
22 |
{item.question}
23 |
24 | {selected == i ? (
25 |
40 | ) : (
41 |
56 | )}
57 |
58 |
59 |
68 |
69 |
70 | ))}
71 |
72 | );
73 | }
74 |
75 | export default AccordionFAQ;
76 |
--------------------------------------------------------------------------------
/pages/api/storage/put.js:
--------------------------------------------------------------------------------
1 | import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2 | import { validateApiKey } from "../auth/middleware";
3 |
4 | // Configure CloudFlare R2 client
5 | const r2Client = new S3Client({
6 | region: "auto",
7 | endpoint: process.env.CLOUDFLARE_R2_ENDPOINT,
8 | credentials: {
9 | accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
10 | secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
11 | },
12 | });
13 |
14 | //
15 | const BUCKET_NAME = process.env.CLOUDFLARE_R2_BUCKET_NAME;
16 |
17 | /**
18 | * API endpoint to store files in CloudFlare R2 bucket
19 | */
20 | export default async function handler(req, res) {
21 | if (req.method !== "POST") {
22 | return res.status(405).json({ error: "Method not allowed" });
23 | }
24 |
25 | try {
26 | const { fileName, content } = req.body;
27 |
28 | // Validate required fields
29 | if (!fileName || content === undefined) {
30 | return res
31 | .status(400)
32 | .json({ error: "fileName and content are required" });
33 | }
34 |
35 | // Authenticate the request
36 | const keyData = await validateApiKey(req.headers.authorization);
37 |
38 | // Extract user information from the request
39 | const { gaiaAddress } = keyData;
40 |
41 | // Construct the R2 object key: {gaiaAddress}/{fileName}
42 | const objectKey = `${gaiaAddress}/${fileName}`;
43 |
44 | // Prepare content for upload
45 | let contentBuffer;
46 | if (typeof content === "string") {
47 | contentBuffer = Buffer.from(content, "utf8");
48 | } else {
49 | contentBuffer = Buffer.from(JSON.stringify(content), "utf8");
50 | }
51 |
52 | // Set content type based on file extension
53 | let contentType = "application/octet-stream";
54 | if (fileName.endsWith(".json")) {
55 | contentType = "application/json";
56 | } else if (fileName.endsWith(".txt")) {
57 | contentType = "text/plain";
58 | } else if (fileName.endsWith(".html")) {
59 | contentType = "text/html";
60 | }
61 |
62 | // Create the put command
63 | const putCommand = new PutObjectCommand({
64 | Bucket: BUCKET_NAME,
65 | Key: objectKey,
66 | Body: contentBuffer,
67 | ContentType: contentType,
68 | });
69 |
70 | // Upload to R2
71 | await r2Client.send(putCommand);
72 |
73 | // Return success response
74 | return res.status(200).json({
75 | data: {
76 | key: objectKey,
77 | url: `https://storage.ballot.gg/${objectKey}`,
78 | },
79 | });
80 | } catch (error) {
81 | console.error("Error uploading to R2:", error);
82 | return res.status(500).json({
83 | error: "Failed to upload file",
84 | message: error.message,
85 | });
86 | }
87 | }
88 |
89 | // Configure API route
90 | export const config = {
91 | api: {
92 | bodyParser: {
93 | sizeLimit: "10mb", // Adjust based on your needs
94 | },
95 | },
96 | };
--------------------------------------------------------------------------------
/pages/api/polls/update-description.js:
--------------------------------------------------------------------------------
1 | import { PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
2 | import { validateApiKey } from "../auth/middleware";
3 |
4 | // Configure CloudFlare R2 client
5 | const r2Client = new S3Client({
6 | region: "auto",
7 | endpoint: process.env.CLOUDFLARE_R2_ENDPOINT,
8 | credentials: {
9 | accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
10 | secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
11 | },
12 | });
13 |
14 | const BUCKET_NAME = process.env.CLOUDFLARE_R2_BUCKET_NAME;
15 |
16 | /**
17 | * API endpoint to update poll description in CloudFlare R2
18 | */
19 | export default async function handler(req, res) {
20 | if (req.method !== "PUT") {
21 | return res.status(405).json({ error: "Method not allowed" });
22 | }
23 |
24 | try {
25 | const { pollId, description } = req.body;
26 |
27 | // Validate required fields
28 | if (!pollId || description === undefined) {
29 | return res
30 | .status(400)
31 | .json({ error: "pollId and description are required" });
32 | }
33 |
34 | // Authenticate the request
35 | const keyData = await validateApiKey(req.headers.authorization);
36 |
37 | // Extract user information from the request
38 | const { gaiaAddress } = keyData;
39 |
40 | // Construct the R2 object keys
41 | const pollObjectKey = `${gaiaAddress}/${pollId}.json`;
42 | const pollIndexKey = `${gaiaAddress}/pollIndex.json`;
43 |
44 | // First, get the existing poll data
45 | const getPollCommand = new GetObjectCommand({
46 | Bucket: BUCKET_NAME,
47 | Key: pollObjectKey,
48 | });
49 |
50 | let pollData;
51 | try {
52 | const pollResponse = await r2Client.send(getPollCommand);
53 | const pollContent = await pollResponse.Body.transformToString();
54 | pollData = JSON.parse(pollContent);
55 | } catch (error) {
56 | if (error.name === 'NoSuchKey') {
57 | return res.status(404).json({ error: "Poll not found" });
58 | }
59 | throw error;
60 | }
61 |
62 | // Update the poll description
63 | pollData.description = description;
64 | pollData.updatedAt = new Date().toISOString();
65 |
66 | // Save updated poll data back to R2
67 | const putPollCommand = new PutObjectCommand({
68 | Bucket: BUCKET_NAME,
69 | Key: pollObjectKey,
70 | Body: Buffer.from(JSON.stringify(pollData), "utf8"),
71 | ContentType: "application/json",
72 | });
73 |
74 | await r2Client.send(putPollCommand);
75 |
76 | // Return success response
77 | return res.status(200).json({
78 | success: true,
79 | data: {
80 | pollId,
81 | description,
82 | updatedAt: pollData.updatedAt,
83 | },
84 | });
85 | } catch (error) {
86 | console.error("Error updating poll description:", error);
87 | return res.status(500).json({
88 | error: "Failed to update poll description",
89 | message: error.message,
90 | });
91 | }
92 | }
93 |
94 | // Configure API route
95 | export const config = {
96 | api: {
97 | bodyParser: {
98 | sizeLimit: "1mb",
99 | },
100 | },
101 | };
--------------------------------------------------------------------------------
/components/common/CountdownTimer.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { calculateDateFromBitcoinBlockHeight } from '../../services/utils';
3 | import styles from '../../styles/CountdownTimer.module.css';
4 |
5 | export default function CountdownTimer({
6 | endAtBlock,
7 | currentBitcoinBlockHeight,
8 | showTimer = true
9 | }) {
10 | const [timeRemaining, setTimeRemaining] = useState({
11 | days: 0,
12 | hours: 0,
13 | minutes: 0,
14 | seconds: 0,
15 | total: 0
16 | });
17 |
18 | useEffect(() => {
19 | if (!endAtBlock || !currentBitcoinBlockHeight || !showTimer) {
20 | return;
21 | }
22 |
23 | // Calculate the target end date once
24 | const targetEndDate = calculateDateFromBitcoinBlockHeight(currentBitcoinBlockHeight, endAtBlock);
25 |
26 | const updateCountdown = () => {
27 | const now = new Date();
28 | const timeDifference = targetEndDate.getTime() - now.getTime();
29 |
30 | if (timeDifference <= 0) {
31 | setTimeRemaining({
32 | days: 0,
33 | hours: 0,
34 | minutes: 0,
35 | seconds: 0,
36 | total: 0
37 | });
38 | return;
39 | }
40 |
41 | const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
42 | const hours = Math.floor((timeDifference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
43 | const minutes = Math.floor((timeDifference % (1000 * 60 * 60)) / (1000 * 60));
44 | const seconds = Math.floor((timeDifference % (1000 * 60)) / 1000);
45 |
46 | setTimeRemaining({
47 | days,
48 | hours,
49 | minutes,
50 | seconds,
51 | total: timeDifference
52 | });
53 | };
54 |
55 | // Update immediately
56 | updateCountdown();
57 |
58 | // Set up interval to update every second
59 | const interval = setInterval(updateCountdown, 1000);
60 |
61 | return () => clearInterval(interval);
62 | }, [endAtBlock, currentBitcoinBlockHeight, showTimer]);
63 |
64 | // Don't render if timer is disabled or poll has ended
65 | if (!showTimer || timeRemaining.total <= 0) {
66 | return null;
67 | }
68 |
69 | const formatNumber = (num) => {
70 | return num.toString().padStart(2, '0');
71 | };
72 |
73 | const formatTimeString = () => {
74 | const parts = [];
75 |
76 | if (timeRemaining.days > 0) {
77 | parts.push(`${formatNumber(timeRemaining.days)}d`);
78 | }
79 |
80 | if (timeRemaining.hours > 0 || timeRemaining.days > 0) {
81 | parts.push(`${formatNumber(timeRemaining.hours)}h`);
82 | }
83 |
84 | parts.push(`${formatNumber(timeRemaining.minutes)}m`);
85 | parts.push(`${formatNumber(timeRemaining.seconds)}s`);
86 |
87 | return parts.join(' ');
88 | };
89 |
90 | return (
91 |
92 | ⏰
93 |
94 | {formatTimeString()}
95 |
96 |
97 | );
98 | }
--------------------------------------------------------------------------------
/pages/api/auth/verify.js:
--------------------------------------------------------------------------------
1 | import { publicKeyToBtcAddress } from "@stacks/encryption";
2 | import { randomBytes } from "crypto";
3 | import { deleteKV, getKV, putKV } from "../utils/cloudFlareKVClient";
4 |
5 | /**
6 | * API endpoint to verify signature and generate API key
7 | * Validates the signed nonce and creates a long-lived API key stored in KV
8 | */
9 | export default async function handler(req, res) {
10 | if (req.method !== "POST") {
11 | return res.status(405).json({ error: "Method not allowed" });
12 | }
13 |
14 | try {
15 | const { publicKey, decryptedNonce } = req.body;
16 |
17 | // Validate required fields
18 | if (!publicKey || !decryptedNonce) {
19 | return res.status(400).json({
20 | error: "publicKey and decryptedNonce are required",
21 | });
22 | }
23 |
24 | // Retrieve nonce from KV storage
25 | const nonceKey = `nonce::${publicKey}`;
26 | const storedNonceData = await getKV(nonceKey);
27 | // Check if nonce exists
28 | if (!storedNonceData) {
29 | return res.status(400).json({
30 | error: "Invalid or expired nonce. Please request a new nonce.",
31 | });
32 | }
33 |
34 | // Parse the nonce data
35 | const nonceData = JSON.parse(storedNonceData);
36 |
37 | // Verify nonce matches and hasn't expired
38 | if (nonceData.nonce !== decryptedNonce.nonce) {
39 | return res.status(400).json({ error: "Nonce mismatch" });
40 | }
41 |
42 | // Check if the nonce has expired
43 | if (Date.now() > nonceData.expiresAt) {
44 | // Clean up expired nonce
45 | await deleteKV(nonceKey);
46 | return res.status(400).json({
47 | error: "Nonce has expired. Please request a new nonce.",
48 | });
49 | }
50 |
51 | // Try to get public key data, handle missing key gracefully
52 | let publicKeyData;
53 | try {
54 | publicKeyData = await getKV(`publicKey::${publicKey}`);
55 | } catch (err) {
56 | console.log("KV get failed for publicKey::" + publicKey, err);
57 | publicKeyData = null;
58 | }
59 | if (publicKeyData) {
60 | // Get the API key
61 | const apiKey = JSON.parse(publicKeyData).apiKey;
62 |
63 | // Return API key to client
64 | return res.status(200).json({
65 | data: {
66 | apiKey,
67 | },
68 | });
69 | }
70 |
71 | // Generate API key
72 | const apiKey = randomBytes(32).toString("hex");
73 |
74 | // Generate gaia address from public key for identification
75 | const gaiaAddress = publicKeyToBtcAddress(publicKey);
76 |
77 | // Store API key in KV
78 | const apiKeyData = {
79 | apiKey,
80 | publicKey,
81 | gaiaAddress,
82 | createdAt: Date.now(),
83 | };
84 |
85 | // Store API key in KV
86 | await Promise.all([
87 | putKV(`publicKey::${publicKey}`, JSON.stringify(apiKeyData)),
88 | putKV(`apiKey::${apiKey}`, JSON.stringify(apiKeyData)),
89 | ]);
90 |
91 | // Clean up the used nonce
92 | await deleteKV(nonceKey);
93 |
94 | // Return API key to client
95 | return res.status(200).json({
96 | data: {
97 | apiKey,
98 | },
99 | });
100 | } catch (error) {
101 | console.error("Error verifying signature:", error);
102 | return res.status(500).json({
103 | error: "Failed to verify signature",
104 | message: error.message,
105 | });
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/services/pox-validation.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { getStacksAPIPrefix } from './auth';
3 |
4 | /**
5 | * Validate if a PoX cycle number is valid by checking Hiro API
6 | * @param {number} poxCycleNumber - The PoX cycle number to validate
7 | * @returns {Promise