diff --git a/src/components/tabs/voting.tsx b/src/components/tabs/voting.tsx
index d7c447a..bc0fc15 100644
--- a/src/components/tabs/voting.tsx
+++ b/src/components/tabs/voting.tsx
@@ -14,6 +14,7 @@ import CCIP014 from "../votes/ccip-014";
import CCIP017 from "../votes/ccip-017";
import CCIP021 from "../votes/ccip-021";
import VoteTitle from "../votes/vote-title";
+import CCIP020 from "../votes/ccip-020";
function Voting() {
return (
@@ -93,6 +94,19 @@ function Voting() {
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/components/votes/ccip-020.tsx b/src/components/votes/ccip-020.tsx
new file mode 100644
index 0000000..ccea8c8
--- /dev/null
+++ b/src/components/votes/ccip-020.tsx
@@ -0,0 +1,189 @@
+import {
+ Box,
+ Button,
+ Divider,
+ Link,
+ ListItem,
+ Spinner,
+ Stack,
+ Stat,
+ StatLabel,
+ StatNumber,
+ Text,
+ UnorderedList,
+ useColorModeValue,
+} from "@chakra-ui/react";
+import { useAtomValue } from "jotai";
+import { useCcip020VoteData } from "../../hooks/use-ccip-020-vote-data";
+import { useCcip020VoteActions } from "../../hooks/use-ccip-020-vote-actions";
+import { formatMicroAmount } from "../../store/common";
+import { hasVotedAtom } from "../../store/ccip-020";
+import VoteProgressBar from "./vote-progress-bar";
+
+function VoteButtons() {
+ const { voteYes, voteNo, isRequestPending } = useCcip020VoteActions();
+ const hasVoted = useAtomValue(hasVotedAtom);
+
+ return (
+ <>
+ {hasVoted ? "Change vote" : "Voting"}:
+
+
+
+
+ >
+ );
+}
+
+function VoteResult() {
+ const voterInfo = useCcip020VoteData("voterInfo");
+
+ return (
+
+ Your Vote:
+
+
+ Recorded Vote: {voterInfo.data?.vote ? "Yes" : "No"}
+
+
+ MIA: {formatMicroAmount(voterInfo.data?.mia)}
+ NYC: {formatMicroAmount(voterInfo.data?.nyc)}
+
+
+
+ );
+}
+
+function CCIP020() {
+ const isVoteActive = useCcip020VoteData("isVoteActive");
+ const voteTotals = useCcip020VoteData("voteTotals");
+ const voterInfo = useCcip020VoteData("voterInfo");
+ const hasVoted = useAtomValue(hasVotedAtom);
+
+ return (
+
+
+
+
+ MIA Cycles
+ 80, 81
+
+
+ NYC Cycles
+ 80, 81
+
+
+
+
+ Yes Vote Count
+
+ {voteTotals.data?.totals.totalVotesYes ?? }
+
+
+
+ No Vote Count
+
+ {voteTotals.data?.totals.totalVotesNo ?? }
+
+
+
+
+
+
+
+ Related CCIPs:
+
+
+ CCIP-020
+
+
+
+
+ Related Contracts:
+
+ ccip-020-graceful-protocol-shutdown
+
+
+
+ Voting Method:
+
+ CCIP-015
+
+
+
+ Details:
+
+ As the CityCoins Protocol prepares for the{" "}
+
+ upcoming Nakamoto release on the Stacks blockchain
+
+ , the accumulation of technical debt with each change has made it
+ increasingly challenging for core contributors and volunteers to keep
+ up with the necessary updates. In light of these challenges and after
+ careful consideration of community feedback, this CCIP proposes a
+ graceful shutdown of the CityCoins Protocol.
+
+
+
+ CCIP-020
+ {" "}
+ implements the CCIP-015 voting mechanism as part of a DAO proposal to
+ disable mining and stacking in the CityCoins protocol, freezing the
+ total supply and unlocking any stacked CityCoins to claim.
+
+
+ {isVoteActive.data && hasVoted ? (
+ <>
+
+ Vote recorded, thank you!
+ Refresh to see stats once the tx confirms.
+ >
+ ) : (
+
+ )}
+ {voterInfo.data && }
+
+ );
+}
+
+export default CCIP020;
diff --git a/src/hooks/use-ccip-020-vote-actions.tsx b/src/hooks/use-ccip-020-vote-actions.tsx
new file mode 100644
index 0000000..84bb063
--- /dev/null
+++ b/src/hooks/use-ccip-020-vote-actions.tsx
@@ -0,0 +1,47 @@
+import { useOpenContractCall } from "@micro-stacks/react";
+import { boolCV } from "micro-stacks/clarity";
+import {
+ CONTRACT_ADDRESS,
+ CONTRACT_NAME,
+ ccip020HasVotedAtom,
+} from "../store/ccip-020";
+import { useSetAtom } from "jotai";
+
+export const useCcip020VoteActions = () => {
+ const { openContractCall, isRequestPending } = useOpenContractCall();
+ const setCcip020HasVoted = useSetAtom(ccip020HasVotedAtom);
+
+ const voteYes = async () => {
+ await openContractCall({
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "vote-on-proposal",
+ functionArgs: [boolCV(true)],
+ onFinish: async (data) => {
+ console.log("Vote Yes on CCIP-020 success!", data);
+ setCcip020HasVoted(true);
+ },
+ onCancel: () => {
+ console.log("Vote Yes on CCIP-020 popup closed!");
+ },
+ });
+ };
+
+ const voteNo = async () => {
+ await openContractCall({
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "vote-on-proposal",
+ functionArgs: [boolCV(false)],
+ onFinish: async (data) => {
+ console.log("Vote No on CCIP-020 success!", data);
+ setCcip020HasVoted(true);
+ },
+ onCancel: () => {
+ console.log("Vote No on CCIP-020 popup closed!");
+ },
+ });
+ };
+
+ return { voteYes, voteNo, isRequestPending };
+};
diff --git a/src/hooks/use-ccip-020-vote-data.tsx b/src/hooks/use-ccip-020-vote-data.tsx
new file mode 100644
index 0000000..3ff74cd
--- /dev/null
+++ b/src/hooks/use-ccip-020-vote-data.tsx
@@ -0,0 +1,39 @@
+import { useAtom } from "jotai";
+import { loadable } from "jotai/utils";
+import {
+ Ccip020Atoms,
+ ccip020IsExecutableQueryAtom,
+ ccip020IsVoteActiveQueryAtom,
+ ccip020VoteTotalsQueryAtom,
+ ccip020VoterInfoQueryAtom,
+} from "../store/ccip-020";
+import { LoadableDataset, extractLoadableState } from "../store/common";
+
+export const useCcip020VoteData = (
+ selector: Ccip020Atoms
+): LoadableDataset => {
+ const ccip020IsExecutableLoader = loadable(ccip020IsExecutableQueryAtom);
+ const ccip020IsVoteActiveLoader = loadable(ccip020IsVoteActiveQueryAtom);
+ const ccip020VoteTotalsLoader = loadable(ccip020VoteTotalsQueryAtom);
+ const ccip020VoterInfoLoader = loadable(ccip020VoterInfoQueryAtom);
+
+ const isExecutable = extractLoadableState(
+ useAtom(ccip020IsExecutableLoader)[0]
+ );
+ const isVoteActive = extractLoadableState(
+ useAtom(ccip020IsVoteActiveLoader)[0]
+ );
+ const voteTotals = extractLoadableState(useAtom(ccip020VoteTotalsLoader)[0]);
+ const voterInfo = extractLoadableState(useAtom(ccip020VoterInfoLoader)[0]);
+
+ switch (selector) {
+ case "isExecutable":
+ return isExecutable;
+ case "isVoteActive":
+ return isVoteActive;
+ case "voteTotals":
+ return voteTotals;
+ case "voterInfo":
+ return voterInfo;
+ }
+};
diff --git a/src/store/ccip-020.ts b/src/store/ccip-020.ts
new file mode 100644
index 0000000..09615eb
--- /dev/null
+++ b/src/store/ccip-020.ts
@@ -0,0 +1,202 @@
+import { atom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
+import { fetchReadOnlyFunction } from "micro-stacks/api";
+import { validateStacksAddress } from "micro-stacks/crypto";
+import { standardPrincipalCV, uintCV } from "micro-stacks/clarity";
+import { stxAddressAtom } from "./stacks";
+
+/////////////////////////
+// TYPES
+/////////////////////////
+
+export type Ccip020Atoms =
+ | "isExecutable"
+ | "isVoteActive"
+ | "voteTotals"
+ | "voterInfo";
+
+export type CityVoteRecord = {
+ totalAmountYes: number;
+ totalAmountNo: number;
+ totalVotesYes: number;
+ totalVotesNo: number;
+};
+
+export type Ccip020VoteTotals = {
+ mia: CityVoteRecord;
+ nyc: CityVoteRecord;
+ total: CityVoteRecord;
+};
+
+export type Ccip020VoterInfo = {
+ mia: number;
+ nyc: number;
+ vote: boolean;
+};
+
+/////////////////////////
+// CONSTANTS
+/////////////////////////
+
+export const CONTRACT_ADDRESS = "SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH";
+export const CONTRACT_NAME = "ccip020-graceful-protocol-shutdown";
+export const CONTRACT_FQ_NAME = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
+
+/////////////////////////
+// LOCALSTORAGE ATOMS
+/////////////////////////
+
+export const ccip020IsExecutableAtom = atomWithStorage(
+ "citycoins-ccip020-isExecutable",
+ false
+);
+export const ccip020IsVoteActiveAtom = atomWithStorage(
+ "citycoins-ccip020-isVoteActive",
+ false
+);
+export const ccip020VoteTotalsAtom = atomWithStorage(
+ "citycoins-ccip020-voteTotals",
+ null
+);
+export const ccip020VoterInfoAtom = atomWithStorage(
+ "citycoins-ccip020-voterInfo",
+ null
+);
+export const ccip020HasVotedAtom = atomWithStorage(
+ "citycoins-ccip020-hasVoted",
+ false
+);
+
+/////////////////////////
+// DERIVED ATOMS
+/////////////////////////
+
+export const hasVotedAtom = atom((get) => {
+ const voterInfo = get(ccip020VoterInfoAtom);
+ const hasVoted = get(ccip020HasVotedAtom);
+ if (voterInfo !== null || hasVoted) {
+ return true;
+ }
+ return false;
+});
+
+/////////////////////////
+// LOADABLE ASYNC ATOMS
+/////////////////////////
+
+export const ccip020IsExecutableQueryAtom = atom(async () => {
+ try {
+ const isExecutable = await getIsExecutable();
+ return isExecutable;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch is-executable for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+export const ccip020IsVoteActiveQueryAtom = atom(async () => {
+ try {
+ const isVoteActive = await getIsVoteActive();
+ return isVoteActive;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch is-vote-active for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+export const ccip020VoteTotalsQueryAtom = atom(async () => {
+ try {
+ const voteTotals = await getVoteTotals();
+ return voteTotals;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch get-vote-totals for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+export const ccip020VoterInfoQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const voterInfo = await getVoterInfo(stxAddress);
+ return voterInfo;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch get-voter-info with ${stxAddress} for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+/////////////////////////
+// HELPER FUNCTIONS
+/////////////////////////
+
+async function getIsExecutable(): Promise {
+ const isExecutableQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "is-executable",
+ functionArgs: [],
+ },
+ true
+ );
+ return isExecutableQuery;
+}
+
+async function getIsVoteActive(): Promise {
+ const isVoteActiveQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "is-vote-active",
+ functionArgs: [],
+ },
+ true
+ );
+ return isVoteActiveQuery;
+}
+
+async function getVoteTotals(): Promise {
+ const voteTotalsQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-vote-totals",
+ functionArgs: [],
+ },
+ true
+ );
+ return voteTotalsQuery;
+}
+
+async function getVoterInfo(voterAddress: string): Promise {
+ if (!validateStacksAddress(voterAddress)) {
+ throw new Error("Invalid STX address");
+ }
+ console.log("Voter Address", voterAddress);
+ const voterIdQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: "ccd003-user-registry",
+ functionName: "get-user-id",
+ functionArgs: [standardPrincipalCV(voterAddress)],
+ },
+ true
+ );
+ console.log("Voter ID", voterIdQuery);
+ const voterInfoQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-voter-info",
+ functionArgs: [uintCV(voterIdQuery)],
+ },
+ true
+ );
+ console.log("Voter Info", voterInfoQuery);
+ return voterInfoQuery;
+}