diff --git a/src/common/constants.ts b/src/common/constants.ts index 5a95e1c5..40246beb 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -46,6 +46,9 @@ export const APP_METADATA: Metadata & { export const NATIVE_TOKEN_ID = "near"; export const NATIVE_TOKEN_DECIMALS = 24; +export const MPDAO_TOKEN_CONTRACT_ACCOUNT_ID = + NETWORK === "mainnet" ? "mpdao-token.near" : "mpdao-token.testnet"; + // List ID of PotLock Public Goods Registry export const PUBLIC_GOODS_REGISTRY_LIST_ID = 1; diff --git a/src/common/contracts/metapool/index.ts b/src/common/contracts/metapool/index.ts new file mode 100644 index 00000000..f79b20d2 --- /dev/null +++ b/src/common/contracts/metapool/index.ts @@ -0,0 +1 @@ +export * from "./vote"; diff --git a/src/common/contracts/metapool/vote/index.ts b/src/common/contracts/metapool/vote/index.ts new file mode 100644 index 00000000..8b951ec7 --- /dev/null +++ b/src/common/contracts/metapool/vote/index.ts @@ -0,0 +1 @@ +export * from "./interface"; diff --git a/src/common/contracts/metapool/vote/interface.d.ts b/src/common/contracts/metapool/vote/interface.d.ts new file mode 100644 index 00000000..4efd8a3d --- /dev/null +++ b/src/common/contracts/metapool/vote/interface.d.ts @@ -0,0 +1,23 @@ +export type MpDaoVoter = { + voter_id: string; + balance_in_contract: string; + + locking_positions: { + index: number; + amount: string; + locking_period: number; + voting_power: string; + unlocking_started_at: number | null; + is_unlocked: boolean; + is_unlocking: boolean; + is_locked: boolean; + }[]; + + voting_power: string; + + vote_positions: { + votable_address: string; + votable_object_id: string; + voting_power: string; + }[]; +}; diff --git a/src/common/services/ft/constants.ts b/src/common/services/ft/constants.ts new file mode 100644 index 00000000..8e19731b --- /dev/null +++ b/src/common/services/ft/constants.ts @@ -0,0 +1,4 @@ +import { MPDAO_TOKEN_CONTRACT_ACCOUNT_ID } from "@/common/constants"; +import { AccountId } from "@/common/types"; + +export const MANUALLY_LISTED_ACCOUNT_IDS: AccountId[] = [MPDAO_TOKEN_CONTRACT_ACCOUNT_ID]; diff --git a/src/common/services/ft/models.ts b/src/common/services/ft/models.ts index 2858f111..1ff9e604 100644 --- a/src/common/services/ft/models.ts +++ b/src/common/services/ft/models.ts @@ -14,6 +14,8 @@ import { refExchangeClient } from "@/common/contracts/ref-finance"; import { bigStringToFloat } from "@/common/lib"; import { AccountId, FungibleTokenMetadata, TokenId } from "@/common/types"; +import { MANUALLY_LISTED_ACCOUNT_IDS } from "./constants"; + export type FtRegistryEntry = { contract_account_id: TokenId; metadata: FungibleTokenMetadata; @@ -74,38 +76,40 @@ export const useFtRegistryStore = create()( ] as [TokenId, FtRegistryEntry], ), - ...tokenContractAccountIds.map(async (contract_account_id) => { - const ftClient = naxiosInstance.contractApi({ - contractId: contract_account_id, - cache: new MemoryCache({ expirationTime: 600 }), - }); - - const metadata = await ftClient - .view<{}, FungibleTokenMetadata>("ft_metadata") - .catch(() => undefined); - - const balance = await ftClient - .view<{ account_id: AccountId }, string>("ft_balance_of", { - args: { account_id: walletApi.accountId ?? "unknown" }, - }) - .catch(() => undefined); - - return metadata === undefined - ? null - : ([ - contract_account_id, - { + ...MANUALLY_LISTED_ACCOUNT_IDS.concat(tokenContractAccountIds).map( + async (contract_account_id) => { + const ftClient = naxiosInstance.contractApi({ + contractId: contract_account_id, + cache: new MemoryCache({ expirationTime: 600 }), + }); + + const metadata = await ftClient + .view<{}, FungibleTokenMetadata>("ft_metadata") + .catch(() => undefined); + + const balance = await ftClient + .view<{ account_id: AccountId }, string>("ft_balance_of", { + args: { account_id: walletApi.accountId ?? "unknown" }, + }) + .catch(() => undefined); + + return metadata === undefined + ? null + : ([ contract_account_id, - metadata, - balance, - - balanceFloat: - balance === undefined - ? balance - : bigStringToFloat(balance, metadata.decimals), - }, - ] as [TokenId, FtRegistryEntry]); - }), + { + contract_account_id, + metadata, + balance, + + balanceFloat: + balance === undefined + ? balance + : bigStringToFloat(balance, metadata.decimals), + }, + ] as [TokenId, FtRegistryEntry]); + }, + ), ]).then( piped( filter(isNonNull), diff --git a/src/common/ui/components/molecules/checklist.tsx b/src/common/ui/components/molecules/checklist.tsx index 426eb8b5..b0a7386f 100644 --- a/src/common/ui/components/molecules/checklist.tsx +++ b/src/common/ui/components/molecules/checklist.tsx @@ -11,11 +11,17 @@ import { cn } from "../../utils"; export type ChecklistProps = { title: string; - breakdown: BasicRequirement[]; + requirements: BasicRequirement[]; isFinalized?: boolean; + error?: Error; }; -export const Checklist: React.FC = ({ title, breakdown, isFinalized = true }) => ( +export const Checklist: React.FC = ({ + title, + requirements, + isFinalized = true, + error, +}) => (
= ({ title, breakdown, isFinali
-
- {breakdown.map(({ title, isSatisfied }) => ( -
- {isSatisfied ? ( - - ) : ( - <> - {isFinalized ? ( - - ) : ( - - )} - - )} + {error ? ( + {error.message} + ) : ( +
    + {requirements.map(({ title, isSatisfied }) => ( +
  • + {isSatisfied ? ( + + ) : ( + <> + {isFinalized ? ( + + ) : ( + + )} + + )} - {title} -
- ))} -
+ {title} + + ))} + + )} ); diff --git a/src/modules/access-control/index.ts b/src/modules/access-control/index.ts index 1465f0dc..8f17729f 100644 --- a/src/modules/access-control/index.ts +++ b/src/modules/access-control/index.ts @@ -1 +1,2 @@ export * from "./components/AccessControlList"; +export * from "./types"; diff --git a/src/modules/access-control/types.ts b/src/modules/access-control/types.ts new file mode 100644 index 00000000..b63c9fd4 --- /dev/null +++ b/src/modules/access-control/types.ts @@ -0,0 +1,5 @@ +import { BasicRequirement } from "@/common/types"; + +export type AccessControlClearanceCheckResult = + | { requirements: BasicRequirement[]; isEveryRequirementSatisfied: boolean; error: null } + | { requirements: null; isEveryRequirementSatisfied: false; error: Error }; diff --git a/src/modules/pot/components/PotHero.tsx b/src/modules/pot/components/PotHero.tsx index 985b1cf0..1aa24dac 100644 --- a/src/modules/pot/components/PotHero.tsx +++ b/src/modules/pot/components/PotHero.tsx @@ -21,22 +21,20 @@ import NewApplicationModal from "./NewApplicationModal"; import { PotStats } from "./PotStats"; import { PotTimeline } from "./PotTimeline"; import { - usePotUserApplicationRequirements, + usePotUserApplicationClearance, usePotUserPermissions, - usePotUserVotingRequirements, + usePotUserVotingClearance, } from "../hooks/clearance"; import { isPotVotingBased } from "../utils/voting"; export type PotHeroProps = ByPotId & {}; export const PotHero: React.FC = ({ potId }) => { - const router = useRouter(); - const isOnVotingPage = router.pathname.includes("votes"); const { data: pot } = indexer.usePot({ potId }); const isVotingBasedPot = isPotVotingBased({ potId }); const { isSignedIn, accountId } = useAuthSession(); - const applicationClearanceBreakdown = usePotUserApplicationRequirements(); - const votingClearanceBreakdown = usePotUserVotingRequirements(); + const applicationClearance = usePotUserApplicationClearance({ potId }); + const votingClearance = usePotUserVotingClearance({ potId }); const { canApply, canDonate, canFund, canChallengePayouts, existingChallengeForUser } = usePotUserPermissions({ potId }); @@ -62,6 +60,13 @@ export const PotHero: React.FC = ({ potId }) => { } else return [pot?.description ?? null, null]; }, [pot?.description]); + // TODO: Implement proper voting stage check + const isVotingStage = useMemo(() => { + if (!pot) return false; + // Add proper voting stage logic here + return false; + }, [pot]); + return ( <> {pot && ( @@ -156,12 +161,15 @@ export const PotHero: React.FC = ({ potId }) => {
{isVotingBasedPot ? ( <> - {isOnVotingPage ? ( - + {isVotingStage ? ( + ) : ( )} diff --git a/src/modules/pot/hooks/clearance.ts b/src/modules/pot/hooks/clearance.ts index 3f0acea6..bc5a0998 100644 --- a/src/modules/pot/hooks/clearance.ts +++ b/src/modules/pot/hooks/clearance.ts @@ -1,11 +1,17 @@ import { useEffect, useMemo, useState } from "react"; +import { prop } from "remeda"; + import { ByPotId, indexer } from "@/common/api/indexer"; +import { MPDAO_TOKEN_CONTRACT_ACCOUNT_ID } from "@/common/constants"; import { Application, Challenge, potClient } from "@/common/contracts/core"; -import { BasicRequirement } from "@/common/types"; +import { ftService } from "@/common/services"; +import { AccessControlClearanceCheckResult } from "@/modules/access-control"; import { useAuthSession } from "@/modules/auth"; import { getDateTime, useIsHuman } from "@/modules/core"; +import { isPotVotingBased } from "../utils/voting"; + export const usePotUserPermissions = ({ potId }: ByPotId) => { const { isSignedIn, accountId } = useAuthSession(); const { data: pot } = indexer.usePot({ potId }); @@ -40,14 +46,17 @@ export const usePotUserPermissions = ({ potId }: ByPotId) => { const canDonate = useMemo(() => publicRoundOpen && accountId, [publicRoundOpen, accountId]); const canFund = useMemo(() => pot && now < getDateTime(pot.matching_round_end), [pot, now]); - const userIsAdminOrGreater = useMemo( - () => pot?.admins.find((adm) => adm.id === accountId) || pot?.owner.id === accountId, + const isAdminOrGreater = useMemo( + () => + pot?.admins.find(({ id: adminAccountId }) => adminAccountId === accountId) || + pot?.owner.id === accountId, + [pot, accountId], ); - const userIsChefOrGreater = useMemo( - () => userIsAdminOrGreater || pot?.chef?.id === accountId, - [userIsAdminOrGreater, pot, accountId], + const isChefOrGreater = useMemo( + () => isAdminOrGreater || pot?.chef?.id === accountId, + [isAdminOrGreater, pot, accountId], ); const applicationOpen = useMemo( @@ -58,8 +67,8 @@ export const usePotUserPermissions = ({ potId }: ByPotId) => { ); const canApply = useMemo( - () => applicationOpen && existingApplication === undefined && !userIsChefOrGreater, - [applicationOpen, existingApplication, userIsChefOrGreater], + () => applicationOpen && existingApplication === undefined && !isChefOrGreater, + [applicationOpen, existingApplication, isChefOrGreater], ); const canChallengePayouts = useMemo( @@ -91,38 +100,79 @@ export const usePotUserPermissions = ({ potId }: ByPotId) => { // TODO: refactor to support multi-mechanism for the V2 milestone /** - *! Heads up! At the moment, this hook only covers one specific use case, - *! as it's built for the mpDAO milestone. + * Heads up! At the moment, this hook only covers one specific use case, + * as it's built for the mpDAO milestone. */ -export const usePotUserApplicationRequirements = (): BasicRequirement[] => { - const { accountId, isVerifiedPublicGoodsProvider } = useAuthSession(); +export const usePotUserApplicationClearance = ({ + potId, +}: ByPotId): AccessControlClearanceCheckResult => { + const { accountId: _, isVerifiedPublicGoodsProvider } = useAuthSession(); + const isVotingBasedPot = isPotVotingBased({ potId }); - // TODO!: calculate this for fox sake - const metaPoolDaoRpgfScore = 0; + const { data: stNear } = ftService.useRegisteredToken({ + tokenId: MPDAO_TOKEN_CONTRACT_ACCOUNT_ID, + }); - return [ - { title: "Verified Project on Potlock", isSatisfied: isVerifiedPublicGoodsProvider }, - { title: "A minimum stake of 500 USD in Meta Pool", isSatisfied: false }, - { title: "A minimum of 50,000 votes", isSatisfied: false }, + console.log(stNear?.balanceFloat); - { - title: "A total of 25 points accumulated for the RPGF score", - isSatisfied: metaPoolDaoRpgfScore >= 25, - }, - ]; + // TODO: calculate this + const metaPoolDaoRpgfScore = 0; + + return useMemo(() => { + const requirements = [ + { title: "Verified Project on Potlock", isSatisfied: isVerifiedPublicGoodsProvider }, + + ...(isVotingBasedPot + ? [ + { title: "An equivalent of 25 USD staked in NEAR on Meta Pool", isSatisfied: false }, + { title: "A minimum of 5000 voting power", isSatisfied: false }, + + { + title: "A total of 10 points accumulated for the RPGF score", + isSatisfied: metaPoolDaoRpgfScore >= 10, + }, + ] + : []), + ]; + + return { + requirements, + isEveryRequirementSatisfied: requirements.every(prop("isSatisfied")), + error: null, + }; + }, [isVerifiedPublicGoodsProvider, isVotingBasedPot, metaPoolDaoRpgfScore]); }; // TODO: refactor to support multi-mechanism for the V2 milestone /** - *! Heads up! At the moment, this hook only covers one specific use case, - *! as it's built for the mpDAO milestone. + * Heads up! At the moment, this hook only covers one specific use case, + * as it's built for the mpDAO milestone. */ -export const usePotUserVotingRequirements = (): BasicRequirement[] => { - const { accountId, account } = useAuthSession(); +export const usePotUserVotingClearance = ({ + potId, +}: ByPotId): AccessControlClearanceCheckResult => { + const { accountId, isVerifiedPublicGoodsProvider } = useAuthSession(); const { nadaBotVerified: isHuman } = useIsHuman(accountId); - - return [ - { title: "Must have an account on Potlock.", isSatisfied: account !== undefined }, - { title: "Must have human verification.", isSatisfied: isHuman }, - ]; + const isVotingBasedPot = isPotVotingBased({ potId }); + + return useMemo(() => { + if (!isVotingBasedPot) { + return { + requirements: null, + isEveryRequirementSatisfied: false, + error: new Error("This pot doesn't support voting mechanisms."), + }; + } else { + const requirements = [ + { title: "Must have an account on Potlock.", isSatisfied: isVerifiedPublicGoodsProvider }, + { title: "Must have human verification.", isSatisfied: isHuman }, + ]; + + return { + requirements, + isEveryRequirementSatisfied: requirements.every(prop("isSatisfied")), + error: null, + }; + } + }, [isHuman, isVerifiedPublicGoodsProvider, isVotingBasedPot]); }; diff --git a/src/modules/pot/hooks/snapshot.json b/src/modules/pot/hooks/snapshot.json new file mode 100644 index 00000000..23a81bda --- /dev/null +++ b/src/modules/pot/hooks/snapshot.json @@ -0,0 +1,24 @@ +{ + "voter_id": "lucascasp.near", + "balance_in_contract": "0", + "locking_positions": [ + { + "index": 0, + "amount": "1447929400", + "locking_period": 300, + "voting_power": "7239647000000000000000000000", + "unlocking_started_at": null, + "is_unlocked": false, + "is_unlocking": false, + "is_locked": true + } + ], + "voting_power": "0", + "vote_positions": [ + { + "votable_address": "metastaking.app", + "votable_object_id": "luganodes.pool.near", + "voting_power": "7239647000000000000000000000" + } + ] +} \ No newline at end of file diff --git a/src/modules/pot/hooks/voting.ts b/src/modules/pot/hooks/voting.ts new file mode 100644 index 00000000..39a37faf --- /dev/null +++ b/src/modules/pot/hooks/voting.ts @@ -0,0 +1,40 @@ +import { ByPotId } from "@/common/api/indexer"; +import { MpDaoVoter } from "@/common/contracts/metapool"; +import { useAuthSession } from "@/modules/auth"; + +import { isPotVotingBased } from "../utils/voting"; + +export const POT_VOTER_MOCK: MpDaoVoter = { + voter_id: "lucascasp.near", + balance_in_contract: "0", + + locking_positions: [ + { + index: 0, + amount: "1447929400", + locking_period: 300, + voting_power: "7239647000000000000000000000", + unlocking_started_at: null, + is_unlocked: false, + is_unlocking: false, + is_locked: true, + }, + ], + + voting_power: "0", + + vote_positions: [ + { + votable_address: "metastaking.app", + votable_object_id: "luganodes.pool.near", + voting_power: "7239647000000000000000000000", + }, + ], +}; + +export const usePotUserVoteWeight = ({ potId }: ByPotId) => { + const { accountId } = useAuthSession(); + const isVotingBasedPot = isPotVotingBased({ potId }); + + // TODO: calculate voting amplifiers +};