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 + + + + + + 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,