diff --git a/package-lock.json b/package-lock.json
index 2acff9d..3e0b564 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
"openai": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-icons": "^3.11.0",
+ "react-icons": "^5.2.1",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
@@ -16899,11 +16899,9 @@
}
},
"node_modules/react-icons": {
- "version": "3.11.0",
- "license": "MIT",
- "dependencies": {
- "camelcase": "^5.0.0"
- },
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz",
+ "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==",
"peerDependencies": {
"react": "*"
}
diff --git a/package.json b/package.json
index 0967f93..7e9a5e9 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"openai": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-icons": "^3.11.0",
+ "react-icons": "^5.2.1",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
diff --git a/src/components/layout/page-content.tsx b/src/components/layout/page-content.tsx
index a45694e..eacedfd 100644
--- a/src/components/layout/page-content.tsx
+++ b/src/components/layout/page-content.tsx
@@ -2,6 +2,7 @@ import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { useAtom } from "jotai";
import { activeTabAtom } from "../../store/common";
import Dashboard from "../tabs/dashboard";
+import RedeemNYC from "../tabs/redeem-nyc";
import MiningClaims from "../tabs/mining-claims";
import StackingClaims from "../tabs/stacking-claims";
import Voting from "../tabs/voting";
@@ -29,6 +30,7 @@ function Content() {
}}
>
Dashboard
+ Redeem NYC
Mining Claims
Stacking Claims
Voting
@@ -37,6 +39,9 @@ function Content() {
+
+
+
diff --git a/src/components/tabs/dashboard.tsx b/src/components/tabs/dashboard.tsx
index 9096269..63fbb4d 100644
--- a/src/components/tabs/dashboard.tsx
+++ b/src/components/tabs/dashboard.tsx
@@ -46,140 +46,136 @@ function Dashboard() {
}
};
+ if (!stxAddress) {
+ return (
+
+ CityCoins Dashboard
+ Wallet connection required to access dashboard.
+
+
+ );
+ }
+
return (
CityCoins Dashboard
- {stxAddress ? (
- <>
-
- {stxAddress}
- {`${transactions.length} transactions detected`}
-
-
-
- {/* Transaction Stats and Filters */}
-
-
-
- Mining TXs
- {miningTransactions.length}
-
- }
- aria-label="Filter transactions"
- title="Filter Transactions"
- size="xs"
- onClick={() => selectTransactions("mining")}
- />
-
-
-
- Mining Claim TXs
- {miningClaimTransactions.length}
-
- }
- aria-label="Filter transactions"
- title="Filter Transactions"
- size="xs"
- onClick={() => selectTransactions("mining-claims")}
- />
-
-
-
- Stacking TXs
- {stackingTransactions.length}
-
- }
- aria-label="Filter transactions"
- title="Filter Transactions"
- size="xs"
- onClick={() => selectTransactions("stacking")}
- />
-
-
-
- Stacking Claim TXs
- {stackingClaimTransactions.length}
-
- }
- aria-label="Filter transactions"
- title="Filter Transactions"
- size="xs"
- onClick={() => selectTransactions("stacking-claims")}
- />
-
-
-
- Voting TXs
- {votingTransactions.length}
-
- }
- aria-label="Filter transactions"
- title="Filter Transactions"
- size="xs"
- onClick={() => selectTransactions("voting")}
- />
-
-
-
-
- >
- ) : (
- <>
- Wallet connection required to access dashboard.
-
- >
- )}
+
+
+ {stxAddress}
+ {`${transactions.length} transactions detected`}
+
+
+
+ {/* Transaction Stats and Filters */}
+
+
+
+ Mining TXs
+ {miningTransactions.length}
+
+ }
+ aria-label="Filter transactions"
+ title="Filter Transactions"
+ size="xs"
+ onClick={() => selectTransactions("mining")}
+ />
+
+
+
+ Mining Claim TXs
+ {miningClaimTransactions.length}
+
+ }
+ aria-label="Filter transactions"
+ title="Filter Transactions"
+ size="xs"
+ onClick={() => selectTransactions("mining-claims")}
+ />
+
+
+
+ Stacking TXs
+ {stackingTransactions.length}
+
+ }
+ aria-label="Filter transactions"
+ title="Filter Transactions"
+ size="xs"
+ onClick={() => selectTransactions("stacking")}
+ />
+
+
+
+ Stacking Claim TXs
+ {stackingClaimTransactions.length}
+
+ }
+ aria-label="Filter transactions"
+ title="Filter Transactions"
+ size="xs"
+ onClick={() => selectTransactions("stacking-claims")}
+ />
+
+
+
+ Voting TXs
+ {votingTransactions.length}
+
+ }
+ aria-label="Filter transactions"
+ title="Filter Transactions"
+ size="xs"
+ onClick={() => selectTransactions("voting")}
+ />
+
+
+
+
);
}
diff --git a/src/components/tabs/mining-claims.tsx b/src/components/tabs/mining-claims.tsx
index 715ae6a..b5265e9 100644
--- a/src/components/tabs/mining-claims.tsx
+++ b/src/components/tabs/mining-claims.tsx
@@ -1,7 +1,22 @@
-import { Heading, Stack } from "@chakra-ui/react";
+import { Heading, Stack, Text } from "@chakra-ui/react";
+import { useAtomValue } from "jotai";
+import { stxAddressAtom } from "../../store/stacks";
import ComingSoon from "../coming-soon";
+import SignIn from "../auth/sign-in";
function MiningClaims() {
+ const stxAddress = useAtomValue(stxAddressAtom);
+
+ if (!stxAddress) {
+ return (
+
+ CityCoins Mining Claims
+ Wallet connection required to access mining claims.
+
+
+ );
+ }
+
return (
CityCoins Mining Claims
diff --git a/src/components/tabs/redeem-nyc.tsx b/src/components/tabs/redeem-nyc.tsx
new file mode 100644
index 0000000..eeac8d8
--- /dev/null
+++ b/src/components/tabs/redeem-nyc.tsx
@@ -0,0 +1,334 @@
+import {
+ Heading,
+ Button,
+ Stat,
+ StatLabel,
+ StatNumber,
+ StatGroup,
+ VStack,
+ HStack,
+ Stack,
+ Text,
+ useDisclosure,
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ ModalFooter,
+ ModalBody,
+ ModalCloseButton,
+ Link,
+ Divider,
+ useToast,
+ Checkbox,
+} from "@chakra-ui/react";
+import { atom, useAtom, useAtomValue } from "jotai";
+import { LuExternalLink, LuRepeat } from "react-icons/lu";
+import SignIn from "../auth/sign-in";
+import { stxAddressAtom } from "../../store/stacks";
+import {
+ ccd012TxIdAtom,
+ redemptionForBalanceAtom,
+ totalBalanceNYCAtom,
+ v1BalanceNYCAtom,
+ v2BalanceNYCAtom,
+} from "../../store/ccd-012";
+import { formatAmount, formatMicroAmount } from "../../store/common";
+import {
+ useCcd012RedeemNyc,
+ useCcd012StackingDao,
+ useCcd012Lisa,
+} from "../../hooks/use-ccd-012";
+
+const consentCheckedAtom = atom(false);
+
+function RedeemNYC() {
+ const toast = useToast();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const [consentChecked, setConsentChecked] = useAtom(consentCheckedAtom);
+
+ const stxAddress = useAtomValue(stxAddressAtom);
+ const [v1BalanceNYC, setV1BalanceNyc] = useAtom(v1BalanceNYCAtom);
+ const [v2BalanceNYC, setV2BalanceNyc] = useAtom(v2BalanceNYCAtom);
+ const totalBalanceNYC = useAtomValue(totalBalanceNYCAtom);
+ const [redemptionForBalance, setRedemptionForBalance] = useAtom(
+ redemptionForBalanceAtom
+ );
+ const ccd012TxId = useAtomValue(ccd012TxIdAtom);
+
+ const { redeemNycCall, isRequestPending } = useCcd012RedeemNyc();
+ const { stackingDaoCall, isRequestPending: isRequestPendingStackingDAO } =
+ useCcd012StackingDao();
+ const { lisaCall, isRequestPending: isRequestPendingLisa } = useCcd012Lisa();
+
+ const refreshBalances = () => {
+ toast({
+ title: "Refreshing balances...",
+ status: "info",
+ duration: 2000,
+ isClosable: true,
+ position: "top",
+ variant: "solid",
+ });
+ console.log("Refreshing balances...");
+ setV1BalanceNyc();
+ setV2BalanceNyc();
+ setRedemptionForBalance();
+ };
+
+ const readyToRedeem = () => {
+ let toastMsg = "";
+ if (!consentChecked) {
+ toastMsg = "Please read and acknowledge the disclaimer.";
+ }
+ if (!stxAddress) {
+ toastMsg =
+ "No STX address detected, please log out and reconnect your wallet.";
+ }
+ if (!v1BalanceNYC || !v2BalanceNYC) {
+ toastMsg =
+ "No NYC balance detected, please refresh balances and try again.";
+ }
+ if (!redemptionForBalance) {
+ toastMsg =
+ "Unable to compute redemption amount, please refresh balances and try again.";
+ }
+ // a little hacky, but works if msg above was never set = no error
+ if (toastMsg === "") return true;
+ // else display msg and exit false
+ toast({
+ title: "Redemption Preparation Error",
+ description: toastMsg,
+ status: "warning",
+ isClosable: true,
+ position: "top",
+ variant: "solid",
+ });
+ return false;
+ };
+
+ const redeemNYC = () => {
+ toast({
+ title: "Redeeming NYC...",
+ status: "info",
+ isClosable: true,
+ position: "top",
+ variant: "solid",
+ });
+ console.log("Redeeming NYC...");
+ readyToRedeem() && redeemNycCall();
+ };
+
+ const redeemForStSTX = () => {
+ toast({
+ title: "Redeeming NYC for stSTX...",
+ status: "info",
+ isClosable: true,
+ position: "top",
+ variant: "solid",
+ });
+ console.log("Redeeming NYC for stSTX...");
+ readyToRedeem() && stackingDaoCall();
+ onClose();
+ };
+
+ const redeemForLiSTX = () => {
+ toast({
+ title: "Redeeming NYC for liSTX...",
+ status: "info",
+ isClosable: true,
+ position: "top",
+ variant: "solid",
+ });
+ console.log("Redeeming NYC for liSTX...");
+ readyToRedeem() && lisaCall();
+ onClose();
+ };
+
+ if (!stxAddress) {
+ return (
+
+ CityCoins NYC Redemption
+ Wallet connection required to access redemption.
+
+
+ );
+ }
+
+ return (
+
+ CityCoins NYC Redemption
+
+ } onClick={refreshBalances}>
+ Refresh Balances
+
+
+
+
+ V1 NYC Balance
+ {v1BalanceNYC ? (
+ {formatAmount(v1BalanceNYC)}
+ ) : (
+
+ (none detected)
+
+ )}
+
+
+ V2 NYC Balance
+ {v2BalanceNYC ? (
+ {formatMicroAmount(v2BalanceNYC)}
+ ) : (
+
+ (none detected)
+
+ )}
+
+
+
+
+
+ Total NYC Balance
+ {formatMicroAmount(totalBalanceNYC)}
+
+
+ Amount for Balance
+ {redemptionForBalance ? (
+ {formatMicroAmount(redemptionForBalance)}
+ ) : (
+
+ (none detected)
+
+ )}
+
+
+
+
+ {ccd012TxId === null ? (
+ <>
+
+
+ >
+ ) : (
+
+ Redemption submitted!
+
+ View on explorer
+
+
+
+ )}
+
+
+
+
+
+ Redeem NYC and Stack STX
+
+
+
+ If you would like to claim and stack in the same transaction,{" "}
+
+ StackingDAO
+ {" "}
+ and{" "}
+
+ LISA
+ {" "}
+ have partnered to offer redemption for stSTX and liSTX.
+
+
+ Please review the resources below before proceeding to fully
+ understand the process through their platform.
+
+
+ Please be aware of how each protocol operates and the associated
+ risks for stSTX or liSTX before continuing.
+
+
+
+ Official StackingDAO Resources
+
+
+ StackingDAO Website
+
+
+
+
+
+ StackingDAO Community
+
+
+
+ Official LISA Resources
+
+
+ LISA Website
+
+
+
+
+
+ LISA Community
+
+
+
+
+
+
+
+ setConsentChecked(e.target.checked)}
+ mb={4}
+ >
+ I acknowledge StackingDAO and LISA are not affiliated with
+ CityCoins and that I am responsible for my own actions.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default RedeemNYC;
diff --git a/src/components/tabs/stacking-claims.tsx b/src/components/tabs/stacking-claims.tsx
index ec3c9ad..3c1c35a 100644
--- a/src/components/tabs/stacking-claims.tsx
+++ b/src/components/tabs/stacking-claims.tsx
@@ -1,7 +1,22 @@
-import { Heading, Stack } from "@chakra-ui/react";
+import { Heading, Stack, Text } from "@chakra-ui/react";
+import { useAtomValue } from "jotai";
+import { stxAddressAtom } from "../../store/stacks";
import ComingSoon from "../coming-soon";
+import SignIn from "../auth/sign-in";
function StackingClaims() {
+ const stxAddress = useAtomValue(stxAddressAtom);
+
+ if (!stxAddress) {
+ return (
+
+ CityCoins Mining Claims
+ Wallet connection required to access stacking claims.
+
+
+ );
+ }
+
return (
CityCoins Stacking Claims
diff --git a/src/components/votes/ccip-022.tsx b/src/components/votes/ccip-022.tsx
index 960abb9..7fe2fba 100644
--- a/src/components/votes/ccip-022.tsx
+++ b/src/components/votes/ccip-022.tsx
@@ -17,13 +17,19 @@ import { useAtomValue } from "jotai";
import { useCcip022VoteData } from "../../hooks/use-ccip-022-vote-data";
import { useCcip022VoteActions } from "../../hooks/use-ccip-022-vote-actions";
import { formatMicroAmount } from "../../store/common";
-import { hasVotedAtom } from "../../store/ccip-022";
-import { Ccip022VoteTotals } from "../../store/ccip-022";
+import { Ccip022VoteTotals, hasVotedAtom } from "../../store/ccip-022";
+import { stxAddressAtom } from "../../store/stacks";
+import SignIn from "../auth/sign-in";
import VoteProgressBarCCIP022 from "./vote-progress-bar-ccip022";
function VoteButtons() {
const { voteYes, voteNo, isRequestPending } = useCcip022VoteActions();
const hasVoted = useAtomValue(hasVotedAtom);
+ const stxAddress = useAtomValue(stxAddressAtom);
+
+ if (!stxAddress) {
+ return ;
+ }
return (
<>
diff --git a/src/hooks/use-ccd-012.tsx b/src/hooks/use-ccd-012.tsx
new file mode 100644
index 0000000..6615df4
--- /dev/null
+++ b/src/hooks/use-ccd-012.tsx
@@ -0,0 +1,287 @@
+import { useEffect } from "react";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
+import { useToast } from "@chakra-ui/react";
+import { ClarityValue, noneCV, principalCV } from "micro-stacks/clarity";
+import { FinishedTxData } from "micro-stacks/connect";
+import { useOpenContractCall } from "@micro-stacks/react";
+import {
+ createAssetInfo,
+ createFungiblePostCondition,
+ createSTXPostCondition,
+ FungibleConditionCode,
+ makeContractSTXPostCondition,
+ PostCondition,
+} from "micro-stacks/transactions";
+import {
+ CONTRACT_ADDRESS,
+ CONTRACT_NAME,
+ LISA_CONTRACT_ADDRESS,
+ LISA_CONTRACT_NAME,
+ LISA_FUNCTION_NAME,
+ NYC_ASSET_NAME,
+ NYC_V1_CONTRACT_ADDRESS,
+ NYC_V1_CONTRACT_NAME,
+ NYC_V2_CONTRACT_ADDRESS,
+ NYC_V2_CONTRACT_NAME,
+ redemptionForBalanceAtom,
+ STACKING_DAO_CONTRACT_ADDRESS,
+ STACKING_DAO_CONTRACT_NAME,
+ STACKING_DAO_FUNCTION_NAME,
+ ccd012TxIdAtom,
+ stSTXRatioAtom,
+ v1BalanceNYCAtom,
+ v2BalanceNYCAtom,
+} from "../store/ccd-012";
+import { stxAddressAtom } from "../store/stacks";
+
+const onFinishToast = (tx: FinishedTxData, toast: any) => {
+ toast({
+ title: "Redemption TX Sent",
+ description: `View on explorer:\nhttps://explorer.hiro.so/txid/${tx.txId}?chain=mainnet`,
+ status: "success",
+ position: "top",
+ variant: "solid",
+ isClosable: true,
+ duration: 9000,
+ });
+ return tx.txId;
+};
+
+const onCancelToast = (toast: any) => {
+ toast({
+ title: "Redemption Cancelled",
+ status: "warning",
+ position: "top",
+ variant: "solid",
+ isClosable: true,
+ });
+};
+
+function buildRedemptionPostConditions(
+ stxAddress: null | string,
+ v1BalanceNYC: null | number,
+ v2BalanceNYC: null | number,
+ redemptionForBalance: null | number
+) {
+ if (stxAddress) {
+ const postConditions: PostCondition[] = [];
+ // add v1 post condition if needed
+ if (v1BalanceNYC !== null && v1BalanceNYC > 0) {
+ postConditions.push(
+ createFungiblePostCondition(
+ stxAddress,
+ FungibleConditionCode.Equal,
+ v1BalanceNYC,
+ createAssetInfo(
+ NYC_V1_CONTRACT_ADDRESS,
+ NYC_V1_CONTRACT_NAME,
+ NYC_ASSET_NAME
+ )
+ )
+ );
+ }
+ // add v2 post condition if needed
+ if (v2BalanceNYC !== null && v2BalanceNYC > 0) {
+ postConditions.push(
+ createFungiblePostCondition(
+ stxAddress,
+ FungibleConditionCode.Equal,
+ v2BalanceNYC,
+ createAssetInfo(
+ NYC_V2_CONTRACT_ADDRESS,
+ NYC_V2_CONTRACT_NAME,
+ NYC_ASSET_NAME
+ )
+ )
+ );
+ }
+ // add redemption for balance from contract
+ if (redemptionForBalance !== null && redemptionForBalance > 0) {
+ postConditions.push(
+ makeContractSTXPostCondition(
+ CONTRACT_ADDRESS,
+ CONTRACT_NAME,
+ FungibleConditionCode.Equal,
+ redemptionForBalance
+ )
+ );
+ }
+ return postConditions;
+ }
+}
+
+export const useCcd012RedeemNyc = () => {
+ const toast = useToast();
+ const stxAddress = useAtomValue(stxAddressAtom);
+ const v1BalanceNYC = useAtomValue(v1BalanceNYCAtom);
+ const v2BalanceNYC = useAtomValue(v2BalanceNYCAtom);
+ const redemptionForBalance = useAtomValue(redemptionForBalanceAtom);
+ const setTxId = useSetAtom(ccd012TxIdAtom);
+ const { openContractCall, isRequestPending } = useOpenContractCall();
+
+ // can set a state atom here for UI feedback
+
+ const postConditions = buildRedemptionPostConditions(
+ stxAddress,
+ v1BalanceNYC,
+ v2BalanceNYC,
+ redemptionForBalance
+ );
+
+ const contractCallParams = {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "redeem-nyc",
+ functionArgs: [],
+ postConditions,
+ onFinish: (finishedTx: FinishedTxData) => {
+ const txId = onFinishToast(finishedTx, toast);
+ setTxId(txId);
+ },
+ onCancel: () => onCancelToast(toast),
+ };
+
+ const redeemNycCall = async () => {
+ await openContractCall(contractCallParams);
+ };
+
+ return { redeemNycCall, isRequestPending };
+};
+
+export const useCcd012StackingDao = () => {
+ const toast = useToast();
+ const stxAddress = useAtomValue(stxAddressAtom);
+ const v1BalanceNYC = useAtomValue(v1BalanceNYCAtom);
+ const v2BalanceNYC = useAtomValue(v2BalanceNYCAtom);
+ const redemptionForBalance = useAtomValue(redemptionForBalanceAtom);
+ const [stSTXRatio, setStSTXRatio] = useAtom(stSTXRatioAtom);
+ const setTxId = useSetAtom(ccd012TxIdAtom);
+ const { openContractCall, isRequestPending } = useOpenContractCall();
+
+ useEffect(() => {
+ const fetchSTXRatio = async () => setStSTXRatio();
+ fetchSTXRatio();
+ }, [setStSTXRatio]);
+
+ const postConditions = buildRedemptionPostConditions(
+ stxAddress,
+ v1BalanceNYC,
+ v2BalanceNYC,
+ redemptionForBalance
+ );
+
+ if (!stxAddress || !postConditions)
+ // return stub function and false for isRequestPending
+ return { stackingDaoCall: () => {}, isRequestPending: false };
+
+ // add to post conditions:
+ // - xfer redemption-stx from user (to StackingDAO)
+ postConditions.push(
+ createSTXPostCondition(
+ stxAddress,
+ FungibleConditionCode.Equal,
+ redemptionForBalance ?? 0
+ )
+ );
+
+ // - xfer stSTX from contract (query amount from contract)
+ // - stSTX token: SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token
+ if (stSTXRatio) {
+ postConditions.push(
+ createFungiblePostCondition(
+ STACKING_DAO_CONTRACT_ADDRESS,
+ FungibleConditionCode.Equal,
+ redemptionForBalance ?? 0,
+ createAssetInfo(STACKING_DAO_CONTRACT_ADDRESS, "ststx-token", "stSTX")
+ )
+ );
+ }
+
+ // function args based on a test tx from the UI:
+ const functionArgs: ClarityValue[] = [];
+ // - reserve: reserve-v1
+ functionArgs.push(principalCV(`${STACKING_DAO_CONTRACT_ADDRESS}.reserve-v1`));
+ // - commission: commission-v2
+ functionArgs.push(
+ principalCV(`${STACKING_DAO_CONTRACT_ADDRESS}.commission-v2`)
+ );
+ // - staking: staking-v0
+ functionArgs.push(principalCV(`${STACKING_DAO_CONTRACT_ADDRESS}.staking-v0`));
+ // - direct-helpers: direct-helpers-v1
+ functionArgs.push(
+ principalCV(`${STACKING_DAO_CONTRACT_ADDRESS}.direct-helpers-v1`)
+ );
+ // - referrer: none
+ functionArgs.push(noneCV());
+ // - pool: none
+ functionArgs.push(noneCV());
+
+ const contractCallParams = {
+ contractAddress: STACKING_DAO_CONTRACT_ADDRESS,
+ contractName: STACKING_DAO_CONTRACT_NAME,
+ functionName: STACKING_DAO_FUNCTION_NAME,
+ functionArgs: functionArgs,
+ postConditions: postConditions,
+ onFinish: (finishedTx: FinishedTxData) => {
+ const txId = onFinishToast(finishedTx, toast);
+ setTxId(txId);
+ },
+ onCancel: () => onCancelToast(toast),
+ };
+
+ const stackingDaoCall = async () => {
+ await openContractCall(contractCallParams);
+ };
+
+ return { stackingDaoCall, isRequestPending };
+};
+
+export const useCcd012Lisa = () => {
+ const toast = useToast();
+ const stxAddress = useAtomValue(stxAddressAtom);
+ const v1BalanceNYC = useAtomValue(v1BalanceNYCAtom);
+ const v2BalanceNYC = useAtomValue(v2BalanceNYCAtom);
+ const redemptionForBalance = useAtomValue(redemptionForBalanceAtom);
+ const setTxId = useSetAtom(ccd012TxIdAtom);
+ const { openContractCall, isRequestPending } = useOpenContractCall();
+
+ const functionArgs: string[] = [];
+ // function args: (none)
+
+ const postConditions = buildRedemptionPostConditions(
+ stxAddress,
+ v1BalanceNYC,
+ v2BalanceNYC,
+ redemptionForBalance
+ );
+ // add to post conditions:
+ // - xfer redemption-stx from user (to LISA)
+ if (stxAddress && postConditions) {
+ postConditions.push(
+ createSTXPostCondition(
+ stxAddress,
+ FungibleConditionCode.Equal,
+ redemptionForBalance ?? 0
+ )
+ );
+ }
+
+ const contractCallParams = {
+ contractAddress: LISA_CONTRACT_ADDRESS,
+ contractName: LISA_CONTRACT_NAME,
+ functionName: LISA_FUNCTION_NAME,
+ functionArgs: functionArgs,
+ postConditions: postConditions,
+ onFinish: (finishedTx: FinishedTxData) => {
+ const txId = onFinishToast(finishedTx, toast);
+ setTxId(txId);
+ },
+ onCancel: () => onCancelToast(toast),
+ };
+
+ const lisaCall = async () => {
+ await openContractCall(contractCallParams);
+ };
+
+ return { lisaCall, isRequestPending };
+};
diff --git a/src/store/ccd-012.ts b/src/store/ccd-012.ts
new file mode 100644
index 0000000..55ad6ac
--- /dev/null
+++ b/src/store/ccd-012.ts
@@ -0,0 +1,489 @@
+import { atom } from "jotai";
+import { atomWithStorage } from "jotai/utils";
+import { fetchReadOnlyFunction } from "micro-stacks/api";
+import { principalCV, uintCV } from "micro-stacks/clarity";
+import { stxAddressAtom } from "./stacks";
+
+/////////////////////////
+// TYPES
+/////////////////////////
+
+type NycRedemptionInfo = {
+ redemptionsEnabled: boolean;
+ blockHeight: number;
+ totalSupply: number;
+ contractBalance: number;
+ redemptionRatio: number;
+};
+
+type AddressNycBalances = {
+ address: string;
+ balanceV1: number;
+ balanceV2: number;
+ totalBalance: number;
+};
+
+type AddressNycRedemptionInfo = {
+ address: string;
+ nycBalances: AddressNycBalances;
+ redemptionAmount: number;
+ redemptionClaims: number;
+};
+
+/////////////////////////
+// CONSTANTS
+/////////////////////////
+
+export const CONTRACT_ADDRESS = "SP8A9HZ3PKST0S42VM9523Z9NV42SZ026V4K39WH";
+export const CONTRACT_NAME = "ccd012-redemption-nyc";
+export const CONTRACT_FQ_NAME = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`;
+
+export const NYC_ASSET_NAME = "newyorkcitycoin";
+
+export const NYC_V1_CONTRACT_ADDRESS =
+ "SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5";
+export const NYC_V1_CONTRACT_NAME = "newyorkcitycoin-token";
+
+export const NYC_V2_CONTRACT_ADDRESS =
+ "SPSCWDV3RKV5ZRN1FQD84YE1NQFEDJ9R1F4DYQ11";
+export const NYC_V2_CONTRACT_NAME = "newyorkcitycoin-token-v2";
+
+export const STACKING_DAO_CONTRACT_ADDRESS =
+ "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG";
+export const STACKING_DAO_CONTRACT_NAME = "cc-redemption-v1";
+export const STACKING_DAO_FQ_NAME = `${STACKING_DAO_CONTRACT_ADDRESS}.${STACKING_DAO_CONTRACT_NAME}`;
+export const STACKING_DAO_FUNCTION_NAME = "deposit";
+
+export const LISA_CONTRACT_ADDRESS = "SPGAB1P3YV109E22KXFJYM63GK0G21BYX50CQ80B";
+export const LISA_CONTRACT_NAME = "redeem-nyc-for-listx";
+export const LISA_FQ_NAME = `${LISA_CONTRACT_ADDRESS}.${LISA_CONTRACT_NAME}`;
+export const LISA_FUNCTION_NAME = "redeem-nyc-and-stack-with-lisa";
+
+export const MICRO = (decimals: number) => Math.pow(10, decimals);
+
+/////////////////////////
+// LOCALSTORAGE ATOMS
+/////////////////////////
+
+const ccd012V1BalanceNYCLocalAtom = atomWithStorage(
+ "citycoins-ccd012-NYCV1Balance",
+ null
+);
+
+const ccd012V2BalanceNYCLocalAtom = atomWithStorage(
+ "citycoins-ccd012-NYCV2Balance",
+ null
+);
+
+export const ccd012IsRedemptionEnabledAtom = atomWithStorage(
+ "citycoins-ccd012-isRedemptionEnabled",
+ false
+);
+
+export const ccd012RedemptionInfoAtom =
+ atomWithStorage(
+ "citycoins-ccd012-redemptionInfo",
+ null
+ );
+
+export const ccd012NycBalancesAtom = atomWithStorage(
+ "citycoins-ccd012-nycBalances",
+ null
+);
+
+export const ccd012RedemptionForBalanceAtom = atomWithStorage(
+ "citycoins-ccd012-redemptionForBalance",
+ null
+);
+
+export const ccd012RedemptionAmountClaimedAtom = atomWithStorage(
+ "citycoins-ccd012-redemptionAmountClaimed",
+ null
+);
+
+export const ccd012UserRedemptionInfoAtom =
+ atomWithStorage(
+ "citycoins-ccd012-userRedemptionInfo",
+ null
+ );
+
+const ccd012stSTXRatioAtom = atomWithStorage(
+ "citycoins-ccd012-stSTXRatio",
+ null
+);
+
+export const ccd012TxIdAtom = atom(null);
+
+/////////////////////////
+// DERIVED ATOMS
+/////////////////////////
+
+export const stSTXRatioAtom = atom(
+ // getter
+ (get) => get(ccd012stSTXRatioAtom),
+ // setter
+ async (get, set) => {
+ const ratio = await get(getStackingDaoRatioQueryAtom);
+ set(ccd012stSTXRatioAtom, ratio);
+ }
+);
+
+export const v1BalanceNYCAtom = atom(
+ // getter
+ (get) => get(ccd012V1BalanceNYCLocalAtom),
+ // setter
+ async (get, set) => {
+ const balance = await get(v1BalanceNYCQueryAtom);
+ if (balance === undefined) return;
+ if (typeof balance === "bigint") {
+ try {
+ set(ccd012V1BalanceNYCLocalAtom, getBalanceFromBigint(balance));
+ } catch (error) {
+ console.error(`Failed to set v1BalanceAtom with bigint: ${error}`);
+ }
+ } else if (typeof balance === "number") {
+ set(ccd012V1BalanceNYCLocalAtom, balance);
+ }
+ }
+);
+
+export const v2BalanceNYCAtom = atom(
+ // getter
+ (get) => get(ccd012V2BalanceNYCLocalAtom),
+ // setter
+ async (get, set) => {
+ const balance = await get(v2BalanceNYCQueryAtom);
+ if (balance === undefined) return;
+ if (typeof balance === "bigint") {
+ try {
+ set(ccd012V2BalanceNYCLocalAtom, getBalanceFromBigint(balance));
+ } catch (error) {
+ console.error(`Failed to set v2BalanceAtom with bigint: ${error}`);
+ }
+ } else if (typeof balance === "number") {
+ set(ccd012V2BalanceNYCLocalAtom, balance);
+ }
+ }
+);
+
+export const totalBalanceNYCAtom = atom((get) => {
+ const v1Balance = (get(v1BalanceNYCAtom) ?? 0) * MICRO(6);
+ const v2Balance = get(v2BalanceNYCAtom) ?? 0;
+ return v1Balance + v2Balance;
+});
+
+export const isRedemptionEnabledAtom = atom(
+ // getter
+ (get) => get(ccd012IsRedemptionEnabledAtom),
+ // setter
+ async (get, set) => {
+ const isRedemptionEnabled = await get(isRedemptionEnabledQueryAtom);
+ set(ccd012IsRedemptionEnabledAtom, isRedemptionEnabled);
+ }
+);
+
+export const redemptionInfoAtom = atom(
+ // getter
+ (get) => get(ccd012RedemptionInfoAtom),
+ // setter
+ async (get, set) => {
+ const redemptionInfo = await get(redemptionInfoQueryAtom);
+ set(ccd012RedemptionInfoAtom, redemptionInfo);
+ }
+);
+
+export const nycBalancesAtom = atom(
+ // getter
+ (get) => get(ccd012NycBalancesAtom),
+ // setter
+ async (get, set) => {
+ const nycBalances = await get(nycBalancesQueryAtom);
+ if (!nycBalances) return;
+ set(ccd012NycBalancesAtom, nycBalances);
+ }
+);
+
+export const redemptionForBalanceAtom = atom(
+ // getter
+ (get) => get(ccd012RedemptionForBalanceAtom),
+ // setter
+ async (get, set) => {
+ const redemptionForBalance = await get(redemptionForBalanceQueryAtom);
+ set(ccd012RedemptionForBalanceAtom, redemptionForBalance);
+ }
+);
+
+export const redemptionAmountClaimed = atom(
+ // getter
+ (get) => get(ccd012RedemptionAmountClaimedAtom),
+ // setter
+ async (get, set) => {
+ const redemptionAmountClaimed = await get(redemptionAmountClaimedQueryAtom);
+ if (!redemptionAmountClaimed) return;
+ set(ccd012RedemptionAmountClaimedAtom, redemptionAmountClaimed);
+ }
+);
+
+export const userRedemptionInfoAtom = atom(
+ // getter
+ (get) => get(ccd012UserRedemptionInfoAtom),
+ // setter
+ async (get, set) => {
+ const userRedemptionInfo = await get(userRedemptionInfoQueryAtom);
+ if (!userRedemptionInfo) return;
+ set(ccd012UserRedemptionInfoAtom, userRedemptionInfo);
+ }
+);
+
+/////////////////////////
+// LOADABLE ASYNC ATOMS
+/////////////////////////
+
+const v1BalanceNYCQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const v1Balance = await getV1Balance(stxAddress);
+ return v1Balance;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch NYC V1 balance for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const v2BalanceNYCQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const v2Balance = await getV2Balance(stxAddress);
+ return v2Balance;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch NYC V2 balance for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const isRedemptionEnabledQueryAtom = atom(async () => {
+ try {
+ const redemptionEnabled = await isRedemptionEnabled();
+ return redemptionEnabled;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch is-redemption-enabled for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const redemptionInfoQueryAtom = atom(async () => {
+ try {
+ const redemptionInfo = await getRedemptionInfo();
+ return redemptionInfo;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch redemption-info for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const nycBalancesQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const nycBalances = await getNycBalances(stxAddress);
+ return nycBalances;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch nyc-balances for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const redemptionForBalanceQueryAtom = atom(async (get) => {
+ const totalBalance = get(totalBalanceNYCAtom);
+ try {
+ const redemptionForBalance = await getRedemptionForBalance(totalBalance);
+ return redemptionForBalance;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch redemption-for-balance for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const redemptionAmountClaimedQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const redemptionAmountClaimed = await getRedemptionAmountClaimed(
+ stxAddress
+ );
+ return redemptionAmountClaimed;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch redemption-amount-claimed for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const userRedemptionInfoQueryAtom = atom(async (get) => {
+ const stxAddress = get(stxAddressAtom);
+ if (stxAddress === null) return undefined;
+ try {
+ const userRedemptionInfo = await getUserRedemptionInfo(stxAddress);
+ return userRedemptionInfo;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch user-redemption-info for ${CONTRACT_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+const getStackingDaoRatioQueryAtom = atom(async () => {
+ try {
+ const ratio = await getStackingDaoRatio();
+ return ratio;
+ } catch (error) {
+ throw new Error(
+ `Failed to fetch stSTX ratio for ${STACKING_DAO_FQ_NAME}: ${error}`
+ );
+ }
+});
+
+/////////////////////////
+// HELPER FUNCTIONS
+/////////////////////////
+
+function getBalanceFromBigint(balance: bigint): number {
+ const numberBalance = Number(balance);
+ if (Number.isSafeInteger(numberBalance)) {
+ return numberBalance;
+ } else {
+ throw new Error(
+ "BigInt value is too large to be safely converted to number"
+ );
+ }
+}
+
+async function getV1Balance(address: string): Promise {
+ const v1Balance = await fetchReadOnlyFunction({
+ contractAddress: NYC_V1_CONTRACT_ADDRESS,
+ contractName: NYC_V1_CONTRACT_NAME,
+ functionName: "get-balance",
+ functionArgs: [principalCV(address)],
+ });
+ return v1Balance;
+}
+
+async function getV2Balance(address: string): Promise {
+ const v2Balance = await fetchReadOnlyFunction({
+ contractAddress: NYC_V2_CONTRACT_ADDRESS,
+ contractName: NYC_V2_CONTRACT_NAME,
+ functionName: "get-balance",
+ functionArgs: [principalCV(address)],
+ });
+ return v2Balance;
+}
+
+async function isRedemptionEnabled(): Promise {
+ const isRedemptionEnabledQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "is-redemption-enabled",
+ functionArgs: [],
+ },
+ true
+ );
+ return isRedemptionEnabledQuery;
+}
+
+async function getRedemptionInfo(): Promise {
+ const redemptionInfoQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-redemption-info",
+ functionArgs: [],
+ },
+ true
+ );
+ return redemptionInfoQuery;
+}
+
+async function getNycBalances(address: string): Promise {
+ const nycBalancesQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-nyc-balances",
+ functionArgs: [address],
+ },
+ true
+ );
+ return nycBalancesQuery;
+}
+
+async function getRedemptionForBalance(
+ balance: number
+): Promise {
+ const redemptionForBalanceQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-redemption-for-balance",
+ functionArgs: [uintCV(balance)],
+ },
+ true
+ );
+ return redemptionForBalanceQuery;
+}
+
+async function getRedemptionAmountClaimed(address: string): Promise {
+ const redemptionAmountClaimedQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-redemption-amount-claimed",
+ functionArgs: [principalCV(address)],
+ },
+ true
+ );
+ return redemptionAmountClaimedQuery;
+}
+
+async function getUserRedemptionInfo(
+ address: string
+): Promise {
+ const userRedemptionInfoQuery =
+ await fetchReadOnlyFunction(
+ {
+ contractAddress: CONTRACT_ADDRESS,
+ contractName: CONTRACT_NAME,
+ functionName: "get-user-redemption-info",
+ functionArgs: [principalCV(address)],
+ },
+ true
+ );
+ return userRedemptionInfoQuery;
+}
+
+// helper to get the stSTX to STX ratio from the StackingDAO contract
+// calls `get-stx-per-ststx` on `SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.data-core-v1`
+// The param `reserve-contract` should be `SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.reserve-v1`.
+// example: return `u1015555`, meaning for 1.015555 STX you will get 1 stSTX.
+export async function getStackingDaoRatio(): Promise {
+ const stackingDaoRatioQuery = await fetchReadOnlyFunction(
+ {
+ contractAddress: STACKING_DAO_CONTRACT_ADDRESS,
+ contractName: "data-core-v1",
+ functionName: "get-stx-per-ststx",
+ functionArgs: [
+ principalCV(`${STACKING_DAO_CONTRACT_ADDRESS}.reserve-v1`),
+ ],
+ },
+ true
+ );
+ return stackingDaoRatioQuery;
+}
diff --git a/src/store/common.ts b/src/store/common.ts
index 0ad918c..52b5700 100644
--- a/src/store/common.ts
+++ b/src/store/common.ts
@@ -27,7 +27,7 @@ export type LoadableDataset = {
export const activeTabAtom = atomWithStorage(
"citycoins-ui-activeTab",
- 3 // temporarily set to voting tab, default: 0
+ 4 // default: Voting
);
// HELPER FUNCTIONS
@@ -43,6 +43,13 @@ export function extractLoadableState(loadedAtom: Loadable) {
return { isLoading, hasError, hasData, error, data };
}
+export function formatAmount(amount: number) {
+ return amount.toLocaleString(navigator.language, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ });
+}
+
export function formatMicroAmount(
amount: number,
decimalsToDivide: number = 6,