From ff31899bffb958831ede7b2cd0151779b13fa54b Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 6 Jun 2024 12:02:31 -0400 Subject: [PATCH] React Query! (#1783) --- .vscode/launch.json | 17 +- apps/dapp/next.config.js | 2 + apps/dapp/package.json | 1 + apps/dapp/pages/[[...tab]].tsx | 37 +- apps/dapp/pages/_app.tsx | 7 +- apps/dapp/pages/dao/[address]/[[...slug]].tsx | 2 +- apps/dapp/pages/dao/[address]/create.tsx | 2 +- .../pages/dao/[address]/proposals/create.tsx | 2 +- apps/sda/pages/[address]/[[...slug]].tsx | 2 +- apps/sda/pages/[address]/create.tsx | 2 +- apps/sda/pages/[address]/proposals/create.tsx | 2 +- apps/sda/pages/_app.tsx | 9 +- packages/state/README.md | 2 + packages/state/contracts/PolytoneVoice.ts | 107 ++ packages/state/contracts/index.ts | 1 + packages/state/index.ts | 1 + packages/state/indexer/query.ts | 19 +- packages/state/package.json | 1 + packages/state/query/client.ts | 60 ++ packages/state/query/index.ts | 2 + packages/state/query/queries/account.ts | 193 ++++ packages/state/query/queries/chain.ts | 201 ++++ packages/state/query/queries/contract.ts | 234 +++++ .../query/queries/contracts/Cw1Whitelist.ts | 132 +++ .../query/queries/contracts/DaoDaoCore.ts | 918 ++++++++++++++++++ .../query/queries/contracts/PolytoneNote.ts | 84 ++ .../query/queries/contracts/PolytoneProxy.ts | 80 ++ .../query/queries/contracts/PolytoneVoice.ts | 95 ++ .../state/query/queries/contracts/index.ts | 6 + .../query/queries/contracts/votingModule.ts | 84 ++ packages/state/query/queries/dao.ts | 63 ++ packages/state/query/queries/index.ts | 9 + packages/state/query/queries/indexer.ts | 148 +++ packages/state/query/queries/polytone.ts | 157 +++ packages/state/query/queries/profile.ts | 338 +++++++ packages/state/recoil/atoms/misc.ts | 7 + packages/state/recoil/atoms/refresh.ts | 7 - packages/state/recoil/selectors/chain.ts | 128 +-- .../recoil/selectors/contracts/DaoCore.v2.ts | 3 +- packages/state/recoil/selectors/dao.ts | 123 +-- packages/state/recoil/selectors/index.ts | 1 - packages/state/recoil/selectors/indexer.ts | 36 - packages/state/recoil/selectors/profile.ts | 309 ------ packages/state/recoil/selectors/treasury.ts | 9 +- packages/state/recoil/selectors/wallet.ts | 4 +- packages/state/utils/contract.ts | 84 +- packages/stateful/README.md | 1 + .../core/authorizations/AuthzExec/index.tsx | 57 +- .../core/dao_governance/CreateDao/index.tsx | 28 +- .../dao_governance/DaoAdminExec/index.tsx | 34 +- .../VetoOrEarlyExecuteDaoProposal/index.tsx | 26 +- .../actions/core/treasury/Spend/index.tsx | 22 +- .../stateful/InstantiateTokenSwap.tsx | 28 +- .../stateful/command/contexts/generic/dao.tsx | 36 +- packages/stateful/components/AddressInput.tsx | 103 +- .../stateful/components/StateProvider.tsx | 59 ++ .../stateful/components/dao/CreateDaoForm.tsx | 16 +- .../dao/DaoApproverProposalContentDisplay.tsx | 7 +- packages/stateful/components/dao/DaoCard.tsx | 15 + .../components/dao/DaoPageWrapper.tsx | 80 +- .../stateful/components/dao/DaoProviders.tsx | 21 +- .../stateful/components/dao/LazyDaoCard.tsx | 23 +- .../components/dao/tabs/SubDaosTab.tsx | 24 +- .../stateful/components/gov/GovSubDaosTab.tsx | 21 +- packages/stateful/components/index.ts | 1 + .../stateful/components/pages/Account.tsx | 9 +- packages/stateful/components/pages/Home.tsx | 85 +- .../components/wallet/WalletProvider.tsx | 8 +- packages/stateful/hooks/index.ts | 2 + packages/stateful/hooks/useEntity.ts | 25 +- packages/stateful/hooks/useLoadingDaos.ts | 88 +- packages/stateful/hooks/useManageProfile.ts | 53 +- packages/stateful/hooks/useProfile.ts | 10 +- .../stateful/hooks/useQueryLoadingData.ts | 61 ++ .../hooks/useQueryLoadingDataWithError.ts | 56 ++ packages/stateful/hooks/useRefreshProfile.ts | 66 +- packages/stateful/index.ts | 1 + packages/stateful/package.json | 1 + .../functions/fetchPrePropose.ts | 3 +- .../functions/fetchPrePropose.ts | 3 +- packages/stateful/queries/dao.ts | 451 +++++++++ packages/stateful/queries/entity.ts | 214 ++++ packages/stateful/queries/index.ts | 2 + packages/stateful/recoil/selectors/dao.ts | 281 +----- packages/stateful/recoil/selectors/entity.ts | 186 ---- packages/stateful/recoil/selectors/index.ts | 1 - .../stateful/server/makeGetDaoStaticProps.ts | 862 +++------------- .../stateful/utils/fetchProposalModules.ts | 25 +- .../components/dao/DaoCard.stories.tsx | 1 + packages/stateless/components/dao/DaoCard.tsx | 3 +- .../stateless/components/dao/DaoImage.tsx | 2 +- packages/stateless/hooks/useCachedLoadable.ts | 13 +- packages/types/components/DaoCard.tsx | 9 + packages/types/components/EntityDisplay.tsx | 12 +- packages/types/contracts/DaoCore.v2.ts | 5 + packages/types/contracts/PolytoneVoice.ts | 35 + packages/types/contracts/common.ts | 4 + packages/types/dao.ts | 7 +- packages/types/misc.ts | 26 +- packages/types/package.json | 3 +- packages/types/proposal-module-adapter.ts | 2 + packages/utils/assets.ts | 2 +- packages/utils/chain.ts | 108 +-- packages/utils/client.ts | 238 +++-- packages/utils/constants/chains.ts | 77 +- packages/utils/contracts.ts | 10 +- packages/utils/conversion.ts | 51 + packages/utils/nft.ts | 20 +- packages/utils/package.json | 1 + packages/utils/profile.ts | 6 +- yarn.lock | 12 + 111 files changed, 4955 insertions(+), 2518 deletions(-) create mode 100644 packages/state/contracts/PolytoneVoice.ts create mode 100644 packages/state/query/client.ts create mode 100644 packages/state/query/index.ts create mode 100644 packages/state/query/queries/account.ts create mode 100644 packages/state/query/queries/chain.ts create mode 100644 packages/state/query/queries/contract.ts create mode 100644 packages/state/query/queries/contracts/Cw1Whitelist.ts create mode 100644 packages/state/query/queries/contracts/DaoDaoCore.ts create mode 100644 packages/state/query/queries/contracts/PolytoneNote.ts create mode 100644 packages/state/query/queries/contracts/PolytoneProxy.ts create mode 100644 packages/state/query/queries/contracts/PolytoneVoice.ts create mode 100644 packages/state/query/queries/contracts/index.ts create mode 100644 packages/state/query/queries/contracts/votingModule.ts create mode 100644 packages/state/query/queries/dao.ts create mode 100644 packages/state/query/queries/index.ts create mode 100644 packages/state/query/queries/indexer.ts create mode 100644 packages/state/query/queries/polytone.ts create mode 100644 packages/state/query/queries/profile.ts delete mode 100644 packages/state/recoil/selectors/profile.ts create mode 100644 packages/stateful/components/StateProvider.tsx create mode 100644 packages/stateful/hooks/useQueryLoadingData.ts create mode 100644 packages/stateful/hooks/useQueryLoadingDataWithError.ts create mode 100644 packages/stateful/queries/dao.ts create mode 100644 packages/stateful/queries/entity.ts create mode 100644 packages/stateful/queries/index.ts delete mode 100644 packages/stateful/recoil/selectors/entity.ts create mode 100644 packages/types/contracts/PolytoneVoice.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 962d45447..0d22cf1e6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,10 +5,21 @@ "version": "0.2.0", "configurations": [ { - "name": "Next.js: debug server-side", - "type": "node-terminal", + "type": "node", "request": "launch", - "command": "yarn dapp dev" + "name": "Next.js: debug server-side", + "runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next", + "env": { + "NODE_OPTIONS": "--inspect" + }, + "cwd": "${workspaceFolder}/apps/dapp", + "console": "integratedTerminal", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceRoot}/apps/dapp/*", + "webpack://?:*/*": "${workspaceFolder}/apps/dapp/*" + } }, { "name": "Next.js: debug client-side", diff --git a/apps/dapp/next.config.js b/apps/dapp/next.config.js index e7555d19f..b5bc20817 100644 --- a/apps/dapp/next.config.js +++ b/apps/dapp/next.config.js @@ -32,6 +32,8 @@ const config = { // Because @cosmos-kit/web3auth uses a Worker ESM import. experimental: { esmExternals: 'loose', + // Increase (to 1 MB) to allow for react-query pre-fetched hydration. + largePageDataBytes: 1 * 1024 * 1024, }, webpack: (config) => { // @cosmos-kit/web3auth uses eccrypto, which uses `stream`. This needs to be diff --git a/apps/dapp/package.json b/apps/dapp/package.json index 8811f51ff..9eb1e36dd 100644 --- a/apps/dapp/package.json +++ b/apps/dapp/package.json @@ -33,6 +33,7 @@ "@keplr-wallet/types": "^0.11.49", "@mui/icons-material": "^5.10.3", "@sentry/nextjs": "^7.80.0", + "@tanstack/react-query": "^5.40.0", "@types/formidable": "^2.0.5", "cors": "^2.8.5", "formidable": "^2.0.1", diff --git a/apps/dapp/pages/[[...tab]].tsx b/apps/dapp/pages/[[...tab]].tsx index 5718b9982..cd64729d3 100644 --- a/apps/dapp/pages/[[...tab]].tsx +++ b/apps/dapp/pages/[[...tab]].tsx @@ -4,8 +4,17 @@ import { GetStaticPaths, GetStaticProps } from 'next' import { serverSideTranslations } from '@dao-dao/i18n/serverSideTranslations' -import { querySnapper } from '@dao-dao/state' -import { Home, StatefulHomeProps } from '@dao-dao/stateful' +import { + daoQueries, + dehydrateSerializable, + makeReactQueryClient, + querySnapper, +} from '@dao-dao/state' +import { + Home, + StatefulHomeProps, + daoQueries as statefulDaoQueries, +} from '@dao-dao/stateful' import { AccountTabId, ChainId, DaoDaoIndexerChainStats } from '@dao-dao/types' import { MAINNET, @@ -54,8 +63,13 @@ export const getStaticProps: GetStaticProps = async ({ : []), ].map((chainId) => getDaoInfoForChainId(chainId, [])) - // Get all or chain-specific stats and TVL. - const [tvl, allStats, monthStats, weekStats] = await Promise.all([ + const queryClient = makeReactQueryClient() + + const [i18nProps, tvl, allStats, monthStats, weekStats] = await Promise.all([ + // Get i18n translations props. + serverSideTranslations(locale, ['translation']), + + // Get all or chain-specific stats and TVL. querySnapper({ query: chainId ? 'daodao-chain-tvl' : 'daodao-all-tvl', parameters: chainId ? { chainId } : undefined, @@ -78,6 +92,17 @@ export const getStaticProps: GetStaticProps = async ({ daysAgo: 7, }, }), + + // Pre-fetch featured DAOs. + queryClient + .fetchQuery(daoQueries.listFeatured()) + .then((featured) => + Promise.all( + featured?.map((dao) => + queryClient.fetchQuery(statefulDaoQueries.info(queryClient, dao)) + ) || [] + ) + ), ]) const validTvl = typeof tvl === 'number' @@ -111,7 +136,7 @@ export const getStaticProps: GetStaticProps = async ({ return { props: { - ...(await serverSideTranslations(locale, ['translation'])), + ...i18nProps, // Chain-specific home page. ...(chainId && { chainId }), // All or chain-specific stats. @@ -125,6 +150,8 @@ export const getStaticProps: GetStaticProps = async ({ }, // Chain x/gov DAOs. ...(chainGovDaos && { chainGovDaos }), + // Dehydrate react-query state with featured DAOs preloaded. + reactQueryDehydratedState: dehydrateSerializable(queryClient), }, // Revalidate every day. revalidate: 24 * 60 * 60, diff --git a/apps/dapp/pages/_app.tsx b/apps/dapp/pages/_app.tsx index 1c718f0c2..12ccaa61f 100644 --- a/apps/dapp/pages/_app.tsx +++ b/apps/dapp/pages/_app.tsx @@ -11,7 +11,7 @@ import { DefaultSeo } from 'next-seo' import type { AppProps } from 'next/app' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { RecoilRoot, useRecoilState, useSetRecoilState } from 'recoil' +import { useRecoilState, useSetRecoilState } from 'recoil' import { activeThemeAtom, @@ -21,6 +21,7 @@ import { import { AppContextProvider, DappLayout, + StateProvider, WalletProvider, } from '@dao-dao/stateful' import { @@ -167,9 +168,9 @@ const DApp = (props: AppProps) => ( }} /> - + - + ) diff --git a/apps/dapp/pages/dao/[address]/[[...slug]].tsx b/apps/dapp/pages/dao/[address]/[[...slug]].tsx index 0301bf7d7..ba40dd493 100644 --- a/apps/dapp/pages/dao/[address]/[[...slug]].tsx +++ b/apps/dapp/pages/dao/[address]/[[...slug]].tsx @@ -37,7 +37,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Dapp, - getProps: async ({ coreAddress }) => ({ + getProps: async ({ daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoPath(DaoPageMode.Dapp, coreAddress), }), }) diff --git a/apps/dapp/pages/dao/[address]/create.tsx b/apps/dapp/pages/dao/[address]/create.tsx index 06540a222..7329efd53 100644 --- a/apps/dapp/pages/dao/[address]/create.tsx +++ b/apps/dapp/pages/dao/[address]/create.tsx @@ -31,7 +31,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Dapp, - getProps: async ({ t, coreAddress }) => ({ + getProps: async ({ t, daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoPath(DaoPageMode.Dapp, coreAddress, 'create'), followingTitle: t('title.createASubDao'), }), diff --git a/apps/dapp/pages/dao/[address]/proposals/create.tsx b/apps/dapp/pages/dao/[address]/proposals/create.tsx index 5829c7c7c..9f1a3ad79 100644 --- a/apps/dapp/pages/dao/[address]/proposals/create.tsx +++ b/apps/dapp/pages/dao/[address]/proposals/create.tsx @@ -39,7 +39,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Dapp, - getProps: ({ t, coreAddress }) => ({ + getProps: ({ t, daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoProposalPath(DaoPageMode.Dapp, coreAddress, 'create'), followingTitle: t('title.createAProposal'), }), diff --git a/apps/sda/pages/[address]/[[...slug]].tsx b/apps/sda/pages/[address]/[[...slug]].tsx index 23738c551..18710e831 100644 --- a/apps/sda/pages/[address]/[[...slug]].tsx +++ b/apps/sda/pages/[address]/[[...slug]].tsx @@ -18,7 +18,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Sda, - getProps: async ({ coreAddress }) => ({ + getProps: async ({ daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoPath(DaoPageMode.Sda, coreAddress), }), }) diff --git a/apps/sda/pages/[address]/create.tsx b/apps/sda/pages/[address]/create.tsx index d57bd1bf2..5fbd897a6 100644 --- a/apps/sda/pages/[address]/create.tsx +++ b/apps/sda/pages/[address]/create.tsx @@ -18,7 +18,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Sda, - getProps: async ({ t, coreAddress }) => ({ + getProps: async ({ t, daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoPath(DaoPageMode.Sda, coreAddress, 'create'), followingTitle: t('title.createASubDao'), }), diff --git a/apps/sda/pages/[address]/proposals/create.tsx b/apps/sda/pages/[address]/proposals/create.tsx index edf11bddb..4f398fbd0 100644 --- a/apps/sda/pages/[address]/proposals/create.tsx +++ b/apps/sda/pages/[address]/proposals/create.tsx @@ -19,7 +19,7 @@ export const getStaticPaths: GetStaticPaths = () => ({ export const getStaticProps: GetStaticProps = makeGetDaoStaticProps({ appMode: DaoPageMode.Sda, - getProps: ({ t, coreAddress }) => ({ + getProps: ({ t, daoInfo: { coreAddress } }) => ({ url: SITE_URL + getDaoProposalPath(DaoPageMode.Sda, coreAddress, 'create'), followingTitle: t('title.createAProposal'), }), diff --git a/apps/sda/pages/_app.tsx b/apps/sda/pages/_app.tsx index 80c386cb0..4a69e3694 100644 --- a/apps/sda/pages/_app.tsx +++ b/apps/sda/pages/_app.tsx @@ -10,8 +10,8 @@ import PlausibleProvider from 'next-plausible' import { DefaultSeo } from 'next-seo' import type { AppProps } from 'next/app' import { useRouter } from 'next/router' -import { Fragment, useEffect, useState } from 'react' -import { RecoilRoot, useRecoilState, useSetRecoilState } from 'recoil' +import { useEffect, useState } from 'react' +import { useRecoilState, useSetRecoilState } from 'recoil' import { activeThemeAtom, @@ -23,6 +23,7 @@ import { DaoPageWrapper, DaoPageWrapperProps, SdaLayout, + StateProvider, WalletProvider, } from '@dao-dao/stateful' import { @@ -181,9 +182,9 @@ const Sda = (props: AppProps) => { }} /> - + - + ) } diff --git a/packages/state/README.md b/packages/state/README.md index d133584d7..292cf4332 100644 --- a/packages/state/README.md +++ b/packages/state/README.md @@ -7,5 +7,7 @@ State retrieval and management for the DAO DAO UI. | Location | Summary | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`contracts`](./contracts) | Smart contract query and execute clients auto-generated with [@cosmwasm/ts-codegen](https://www.npmjs.com/package/@cosmwasm/ts-codegen). | +| [`graphql`](./graphql) | GraphQL-related state, such as the Stargaze API. | | [`indexer`](./indexer) | Functions for accessing the DAO DAO indexer. | +| [`query`](./query) | [React Query](https://tanstack.com/query/latest/docs/framework/react/overview)-related client and queries. | | [`recoil`](./recoil) | [Recoil](https://recoiljs.org/) atoms and selectors for loading and caching state. | diff --git a/packages/state/contracts/PolytoneVoice.ts b/packages/state/contracts/PolytoneVoice.ts new file mode 100644 index 000000000..bbd510de4 --- /dev/null +++ b/packages/state/contracts/PolytoneVoice.ts @@ -0,0 +1,107 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +import { Coin, StdFee } from '@cosmjs/amino' +import { + CosmWasmClient, + ExecuteResult, + SigningCosmWasmClient, +} from '@cosmjs/cosmwasm-stargate' + +import { Binary, SenderInfo } from '@dao-dao/types/contracts/PolytoneVoice' + +export interface PolytoneVoiceReadOnlyInterface { + contractAddress: string + senderInfoForProxy: ({ proxy }: { proxy: string }) => Promise +} +export class PolytoneVoiceQueryClient + implements PolytoneVoiceReadOnlyInterface +{ + client: CosmWasmClient + contractAddress: string + constructor(client: CosmWasmClient, contractAddress: string) { + this.client = client + this.contractAddress = contractAddress + this.senderInfoForProxy = this.senderInfoForProxy.bind(this) + } + senderInfoForProxy = async ({ + proxy, + }: { + proxy: string + }): Promise => { + return this.client.queryContractSmart(this.contractAddress, { + sender_info_for_proxy: { + proxy, + }, + }) + } +} +export interface PolytoneVoiceInterface extends PolytoneVoiceReadOnlyInterface { + contractAddress: string + sender: string + rx: ( + { + connectionId, + counterpartyPort, + data, + }: { + connectionId: string + counterpartyPort: string + data: Binary + }, + fee?: number | StdFee | 'auto', + memo?: string, + _funds?: Coin[] + ) => Promise +} +export class PolytoneVoiceClient + extends PolytoneVoiceQueryClient + implements PolytoneVoiceInterface +{ + client: SigningCosmWasmClient + sender: string + contractAddress: string + constructor( + client: SigningCosmWasmClient, + sender: string, + contractAddress: string + ) { + super(client, contractAddress) + this.client = client + this.sender = sender + this.contractAddress = contractAddress + this.rx = this.rx.bind(this) + } + rx = async ( + { + connectionId, + counterpartyPort, + data, + }: { + connectionId: string + counterpartyPort: string + data: Binary + }, + fee: number | StdFee | 'auto' = 'auto', + memo?: string, + _funds?: Coin[] + ): Promise => { + return await this.client.execute( + this.sender, + this.contractAddress, + { + rx: { + connection_id: connectionId, + counterparty_port: counterpartyPort, + data, + }, + }, + fee, + memo, + _funds + ) + } +} diff --git a/packages/state/contracts/index.ts b/packages/state/contracts/index.ts index 769157de0..e253fa69c 100644 --- a/packages/state/contracts/index.ts +++ b/packages/state/contracts/index.ts @@ -92,4 +92,5 @@ export { } from './PolytoneListener' export { PolytoneNoteClient, PolytoneNoteQueryClient } from './PolytoneNote' export { PolytoneProxyClient, PolytoneProxyQueryClient } from './PolytoneProxy' +export { PolytoneVoiceClient, PolytoneVoiceQueryClient } from './PolytoneVoice' export { Sg721BaseClient, Sg721BaseQueryClient } from './Sg721Base' diff --git a/packages/state/index.ts b/packages/state/index.ts index 3af884ad4..26e0a2327 100644 --- a/packages/state/index.ts +++ b/packages/state/index.ts @@ -3,3 +3,4 @@ export * from './graphql' export * from './indexer' export * from './recoil' export * from './utils' +export * from './query' diff --git a/packages/state/indexer/query.ts b/packages/state/indexer/query.ts index bd15ab3e5..f934f226f 100644 --- a/packages/state/indexer/query.ts +++ b/packages/state/indexer/query.ts @@ -69,22 +69,19 @@ export const queryIndexer = async ({ }), }) - const response = await fetch( - INDEXER_URL + - `/${chainId}/${type}/${address}/${formula}?${params.toString()}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ) + const path = `/${chainId}/${type}/${address}/${formula}?${params.toString()}` + const response = await fetch(INDEXER_URL + path, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) const body = await response.text() if (response.status >= 300) { throw new Error( - `Error querying indexer for ${type}/${address}/${formula}: ${response.status} ${body}`.trim() + `Error querying indexer for ${path}: ${response.status} ${body}`.trim() ) } diff --git a/packages/state/package.json b/packages/state/package.json index 422e55e72..1d6064cf0 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -17,6 +17,7 @@ "@cosmjs/proto-signing": "^0.32.3", "@cosmjs/stargate": "^0.32.3", "@dao-dao/utils": "2.4.0-rc.8", + "@tanstack/react-query": "^5.40.0", "graphql": "^16.8.1", "json5": "^2.2.0", "lodash.uniq": "^4.5.0", diff --git a/packages/state/query/client.ts b/packages/state/query/client.ts new file mode 100644 index 000000000..885bf6213 --- /dev/null +++ b/packages/state/query/client.ts @@ -0,0 +1,60 @@ +import { + DehydratedState, + QueryClient, + QueryKey, + dehydrate, + hydrate, +} from '@tanstack/react-query' + +/** + * Make a new instance of the react query client. + */ +export const makeReactQueryClient = ( + /** + * Optionally hydrate the query client with dehydrated state. + */ + dehydratedState?: DehydratedState +) => { + const client = new QueryClient({ + defaultOptions: { + queries: { + // Global default to 60 seconds. + staleTime: 60 * 1000, + }, + }, + }) + + // Hydate if dehydrated state is provided. + if (dehydratedState) { + hydrate(client, dehydratedState) + } + + return client +} + +/** + * Dehydrate query client and remove undefined values so it can be serialized. + */ +export const dehydrateSerializable: typeof dehydrate = (...args) => { + const dehydrated = dehydrate(...args) + return { + mutations: dehydrated.mutations, + queries: dehydrated.queries.map(({ queryKey, ...query }) => ({ + ...query, + queryKey: removeUndefinedFromQueryKey(queryKey) as QueryKey, + })), + } +} + +const removeUndefinedFromQueryKey = (value: unknown): unknown => + typeof value === 'object' && value !== null + ? Object.fromEntries( + Object.entries(value).flatMap(([k, v]) => + v === undefined ? [] : [[k, removeUndefinedFromQueryKey(v)]] + ) + ) + : Array.isArray(value) + ? value.flatMap((v) => + v === undefined ? [] : [removeUndefinedFromQueryKey(v)] + ) + : value diff --git a/packages/state/query/index.ts b/packages/state/query/index.ts new file mode 100644 index 000000000..290cc9f70 --- /dev/null +++ b/packages/state/query/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './queries' diff --git a/packages/state/query/queries/account.ts b/packages/state/query/queries/account.ts new file mode 100644 index 000000000..78507819c --- /dev/null +++ b/packages/state/query/queries/account.ts @@ -0,0 +1,193 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { Account, AccountType } from '@dao-dao/types' +import { + ICA_CHAINS_TX_PREFIX, + getConfiguredChainConfig, + getIbcTransferInfoBetweenChains, + ibcProtoRpcClientRouter, +} from '@dao-dao/utils' + +import { chainQueries } from './chain' +import { contractQueries } from './contract' +import { daoDaoCoreQueries } from './contracts/DaoDaoCore' +import { polytoneQueries } from './polytone' + +/** + * Fetch the list of accounts associated with the specified address, with + * support for: + * - detecting if the address is a polytone proxy + * - automatically loading a DAO's registered ICAs + */ +export const fetchAccountList = async ( + queryClient: QueryClient, + { + chainId, + address, + includeIcaChains, + }: { + chainId: string + address: string + /** + * Optionally check for ICAs on these chain IDs. + */ + includeIcaChains?: string[] + } +): Promise => { + const chainConfig = getConfiguredChainConfig(chainId) + if (chainConfig && chainConfig.name === address) { + address = await queryClient.fetchQuery( + chainQueries.moduleAddress({ + chainId, + name: chainConfig.name, + }) + ) + } + + const [isDao, isPolytoneProxy] = await Promise.all([ + queryClient.fetchQuery( + contractQueries.isDao(queryClient, { chainId, address }) + ), + queryClient.fetchQuery( + contractQueries.isPolytoneProxy(queryClient, { chainId, address }) + ), + ]) + + // If this is a DAO, get its polytone proxies and registered ICAs (which is a + // chain the DAO has indicated it has an ICA on by storing an item in its KV). + const [polytoneProxies, registeredIcas] = isDao + ? await Promise.all([ + queryClient.fetchQuery( + polytoneQueries.proxies(queryClient, { chainId, address }) + ), + queryClient.fetchQuery( + daoDaoCoreQueries.listAllItems(queryClient, { + chainId, + contractAddress: address, + args: { + prefix: ICA_CHAINS_TX_PREFIX, + }, + }) + ), + ]) + : [] + + const mainAccount: Account = { + chainId, + address, + type: isPolytoneProxy ? AccountType.Polytone : AccountType.Native, + } + + const allAccounts: Account[] = [ + // Main account. + mainAccount, + // Polytone. + ...Object.entries(polytoneProxies || {}).map( + ([chainId, address]): Account => ({ + chainId, + address, + type: AccountType.Polytone, + }) + ), + ] + + // If main account is native, load ICA accounts. + const icaChains = + mainAccount.type === AccountType.Native + ? [ + ...(registeredIcas || []).map(([key]) => key), + ...(includeIcaChains || []), + ] + : [] + + const icas = await Promise.allSettled( + icaChains.map((destChainId) => + queryClient.fetchQuery( + accountQueries.remoteIcaAddress({ + srcChainId: mainAccount.chainId, + address: mainAccount.address, + destChainId, + }) + ) + ) + ) + + // Add ICA accounts. + icas.forEach((addressLoadable, index) => { + if (addressLoadable.status === 'fulfilled' && addressLoadable.value) { + allAccounts.push({ + type: AccountType.Ica, + chainId: icaChains[index], + address: addressLoadable.value, + }) + } + }) + + return allAccounts +} + +/** + * Fetch ICA address on host (`destChainId`) controlled by `address` on + * controller (`srcChainId`). + */ +export const fetchRemoteIcaAddress = async ({ + srcChainId, + address, + destChainId, +}: { + srcChainId: string + address: string + destChainId: string +}): Promise => { + const { + sourceChain: { connection_id }, + } = getIbcTransferInfoBetweenChains(srcChainId, destChainId) + const ibcClient = await ibcProtoRpcClientRouter.connect(srcChainId) + + try { + const account = + await ibcClient.applications.interchain_accounts.controller.v1.interchainAccount( + { + owner: address, + connectionId: connection_id, + } + ) + + return account.address + } catch (err) { + // On lookup failure, return undefined. + if ( + err instanceof Error && + err.message.includes('failed to retrieve account address') && + err.message.includes('key not found') + ) { + return null + } + + // Rethrow all other errors. + throw err + } +} + +export const accountQueries = { + /** + * Fetch the list of accounts associated with the specified address. + */ + list: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['account', 'list', options], + queryFn: () => fetchAccountList(queryClient, options), + }), + /** + * Fetch ICA address on host (`destChainId`) controlled by `address` on + * controller (`srcChainId`). + */ + remoteIcaAddress: (options: Parameters[0]) => + queryOptions({ + queryKey: ['account', 'remoteIcaAddress', options], + queryFn: () => fetchRemoteIcaAddress(options), + }), +} diff --git a/packages/state/query/queries/chain.ts b/packages/state/query/queries/chain.ts new file mode 100644 index 000000000..735660229 --- /dev/null +++ b/packages/state/query/queries/chain.ts @@ -0,0 +1,201 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { ModuleAccount } from '@dao-dao/types/protobuf/codegen/cosmos/auth/v1beta1/auth' +import { + cosmWasmClientRouter, + cosmosProtoRpcClientRouter, + isValidBech32Address, +} from '@dao-dao/utils' + +/** + * Fetch the module address associated with the specified name. + */ +const fetchChainModuleAddress = async ({ + chainId, + name, +}: { + chainId: string + name: string +}): Promise => { + const client = await cosmosProtoRpcClientRouter.connect(chainId) + + let account: ModuleAccount | undefined + try { + const response = await client.auth.v1beta1.moduleAccountByName({ + name, + }) + account = response?.account + } catch (err) { + // Some chains don't support getting a module account by name directly, so + // fallback to getting all module accounts. + if (err instanceof Error && err.message.includes('unknown query path')) { + const { accounts } = await client.auth.v1beta1.moduleAccounts({}) + account = accounts.find( + (acc) => + 'name' in acc && (acc as unknown as ModuleAccount).name === name + ) as unknown as ModuleAccount | undefined + } else { + // Rethrow other errors. + throw err + } + } + + if (!account) { + throw new Error(`Failed to find ${name} module address.`) + } + + return 'baseAccount' in account ? account.baseAccount?.address ?? '' : '' +} + +/** + * Fetch the module name associated with the specified address. Returns null if + * not a module account. + */ +const fetchChainModuleName = async ({ + chainId, + address, +}: { + chainId: string + address: string +}): Promise => { + const client = await cosmosProtoRpcClientRouter.connect(chainId) + + try { + const { account } = await client.auth.v1beta1.account({ + address, + }) + + if (!account) { + return null + } + + // If not decoded automatically... + if (account.typeUrl === ModuleAccount.typeUrl) { + return ModuleAccount.decode(account.value).name + + // If decoded automatically... + } else if (account.$typeUrl === ModuleAccount.typeUrl) { + return (account as unknown as ModuleAccount).name + } + } catch (err) { + // If no account found, return null. + if ( + err instanceof Error && + err.message.includes('not found: key not found') + ) { + return null + } + + // Rethrow other errors. + throw err + } + + return null +} + +/** + * Check whether or not the address is a chain module, optionally with a + * specific name. + */ +export const isAddressModule = async ( + queryClient: QueryClient, + { + chainId, + address, + moduleName, + }: { + chainId: string + address: string + /** + * If defined, check that the module address matches the specified name. + */ + moduleName?: string + } +): Promise => { + if (!isValidBech32Address(address)) { + return false + } + + try { + const name = await queryClient.fetchQuery( + chainQueries.moduleName({ + chainId, + address, + }) + ) + + // Null if not a module. + if (!name) { + return false + } + + // If name to check provided, check it. Otherwise, return true. + return !moduleName || name === moduleName + } catch (err) { + // If invalid address, return false. Should never happen because of the + // check at the beginning, but just in case. + if ( + err instanceof Error && + err.message.includes('decoding bech32 failed') + ) { + return false + } + + // Rethrow other errors. + throw err + } +} + +/** + * Fetch the timestamp for a given block height. + */ +export const fetchBlockTimestamp = async ({ + chainId, + height, +}: { + chainId: string + height: number +}): Promise => { + const client = await cosmWasmClientRouter.connect(chainId) + return new Date((await client.getBlock(height)).header.time).getTime() +} + +export const chainQueries = { + /** + * Fetch the module address associated with the specified name. + */ + moduleAddress: (options: Parameters[0]) => + queryOptions({ + queryKey: ['chain', 'moduleAddress', options], + queryFn: () => fetchChainModuleAddress(options), + }), + /** + * Fetch the module name associated with the specified address. Returns null + * if not a module account. + */ + moduleName: (options: Parameters[0]) => + queryOptions({ + queryKey: ['chain', 'moduleName', options], + queryFn: () => fetchChainModuleName(options), + }), + /** + * Check whether or not the address is a chain module, optionally with a + * specific name. + */ + isAddressModule: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['chain', 'isAddressModule', options], + queryFn: () => isAddressModule(queryClient, options), + }), + /** + * Fetch the timestamp for a given block height. + */ + blockTimestamp: (options: Parameters[0]) => + queryOptions({ + queryKey: ['chain', 'blockTimestamp', options], + queryFn: () => fetchBlockTimestamp(options), + }), +} diff --git a/packages/state/query/queries/contract.ts b/packages/state/query/queries/contract.ts new file mode 100644 index 000000000..94d4f1dc3 --- /dev/null +++ b/packages/state/query/queries/contract.ts @@ -0,0 +1,234 @@ +import { fromUtf8, toUtf8 } from '@cosmjs/encoding' +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { InfoResponse } from '@dao-dao/types' +import { + ContractName, + DAO_CORE_CONTRACT_NAMES, + INVALID_CONTRACT_ERROR_SUBSTRINGS, + cosmWasmClientRouter, + getChainForChainId, + isValidBech32Address, +} from '@dao-dao/utils' + +import { chainQueries } from './chain' +import { indexerQueries } from './indexer' + +/** + * Fetch contract info stored in state, which contains its name and version. + */ +export const fetchContractInfo = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + try { + return { + info: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: address, + formula: 'info', + }) + ), + } + } catch (error) { + // Rethrow contract not found errors. + if ( + error instanceof Error && + error.message.includes('contract not found') + ) { + throw error + } + + console.error(error) + } + + // If indexer fails, fallback to querying chain. + const client = await cosmWasmClientRouter.connect(chainId) + const { data: contractInfo } = await client[ + 'forceGetQueryClient' + ]().wasm.queryContractRaw(address, toUtf8('contract_info')) + if (contractInfo) { + const info: InfoResponse = { + info: JSON.parse(fromUtf8(contractInfo)), + } + return info + } + + throw new Error('Failed to query contract info for contract: ' + address) +} + +/** + * Check if a contract is a specific contract by name. + */ +export const fetchIsContract = async ( + queryClient: QueryClient, + { + chainId, + address, + nameOrNames, + }: { + chainId: string + address: string + nameOrNames: string | string[] + } +): Promise => { + if ( + !isValidBech32Address(address, getChainForChainId(chainId).bech32_prefix) + ) { + return false + } + + try { + const { + info: { contract }, + } = await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address, + }) + ) + + return Array.isArray(nameOrNames) + ? nameOrNames.some((name) => contract.includes(name)) + : contract.includes(nameOrNames) + } catch (err) { + if ( + err instanceof Error && + INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => + (err as Error).message.includes(substring) + ) + ) { + return false + } + + // Rethrow other errors because it should not have failed. + throw err + } +} + +/** + * Fetch contract instantiation time. + */ +export const fetchContractInstantiationTime = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + try { + return new Date( + await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: address, + formula: 'instantiatedAt', + // This never changes, and the fallback is unreliable, so attempt to + // query even if the indexer is behind. + noFallback: true, + }) + ) + ).getTime() + } catch (error) { + console.error(error) + } + + // If indexer fails, fallback to querying chain. + const client = await cosmWasmClientRouter.connect(chainId) + const events = await client.searchTx([ + { key: 'instantiate._contract_address', value: address }, + ]) + + if (events.length === 0) { + throw new Error( + 'Failed to find instantiation time due to no instantiation events for contract: ' + + address + ) + } + + return await queryClient.fetchQuery( + chainQueries.blockTimestamp({ + chainId, + height: events[0].height, + }) + ) +} + +export const contractQueries = { + /** + * Fetch contract info stored in state, which contains its name and version. + */ + info: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['contract', 'info', options], + queryFn: () => fetchContractInfo(queryClient, options), + }), + /** + * Check if a contract is a specific contract by name. + */ + isContract: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['contract', 'isContract', options], + queryFn: () => fetchIsContract(queryClient, options), + }), + /** + * Check if a contract is a DAO. + */ + isDao: ( + queryClient: QueryClient, + options: Omit[1], 'nameOrNames'> + ) => + contractQueries.isContract(queryClient, { + ...options, + nameOrNames: DAO_CORE_CONTRACT_NAMES, + }), + /** + * Check if a contract is a Polytone proxy. + */ + isPolytoneProxy: ( + queryClient: QueryClient, + options: Omit[1], 'nameOrNames'> + ) => + contractQueries.isContract(queryClient, { + ...options, + nameOrNames: ContractName.PolytoneProxy, + }), + /** + * Check if a contract is a cw1-whitelist. + */ + isCw1Whitelist: ( + queryClient: QueryClient, + options: Omit[1], 'nameOrNames'> + ) => + contractQueries.isContract(queryClient, { + ...options, + nameOrNames: ContractName.Cw1Whitelist, + }), + /** + * Fetch contract instantiation time. + */ + instantiationTime: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['contract', 'instantiationTime', options], + queryFn: () => fetchContractInstantiationTime(queryClient, options), + }), +} diff --git a/packages/state/query/queries/contracts/Cw1Whitelist.ts b/packages/state/query/queries/contracts/Cw1Whitelist.ts new file mode 100644 index 000000000..b73bbbf65 --- /dev/null +++ b/packages/state/query/queries/contracts/Cw1Whitelist.ts @@ -0,0 +1,132 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { AdminListResponse } from '@dao-dao/types/contracts/Cw1Whitelist' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { Cw1WhitelistQueryClient } from '../../../contracts/Cw1Whitelist' +import { contractQueries } from '../contract' +import { indexerQueries } from '../indexer' + +export const cw1WhitelistQueryKeys = { + contract: [ + { + contract: 'cw1Whitelist', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...cw1WhitelistQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + adminList: (contractAddress: string, args?: Record) => + [ + { + ...cw1WhitelistQueryKeys.address(contractAddress)[0], + method: 'admin_list', + ...(args && { args }), + }, + ] as const, + /** + * If this is a cw1-whitelist, return the admins. Otherwise, return null. + */ + adminsIfCw1Whitelist: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...cw1WhitelistQueryKeys.address(contractAddress)[0], + method: 'adminsIfCw1Whitelist', + ...(args && { args }), + }, + ] as const, +} +export const cw1WhitelistQueries = { + adminList: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: Cw1WhitelistAdminListQuery + ): UseQueryOptions => ({ + queryKey: cw1WhitelistQueryKeys.adminList(contractAddress), + queryFn: async () => { + let indexerNonExistent = false + try { + const adminList = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw1Whitelist/adminList', + }) + ) + if (adminList) { + return adminList + } else { + indexerNonExistent = true + } + } catch (error) { + console.error(error) + } + + if (indexerNonExistent) { + throw new Error('Admin list not found') + } + + // If indexer query fails, fallback to contract query. + return new Cw1WhitelistQueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).adminList() + }, + ...options, + }), + /** + * If this is a cw1-whitelist, return the admins. Otherwise, return null. + */ + adminsIfCw1Whitelist: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + options, + }: Cw1WhitelistAdminsIfCw1WhitelistQuery + ): UseQueryOptions => ({ + queryKey: cw1WhitelistQueryKeys.adminsIfCw1Whitelist(contractAddress), + queryFn: async () => { + const isCw1Whitelist = await queryClient.fetchQuery( + contractQueries.isCw1Whitelist(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (!isCw1Whitelist) { + return null + } + + return ( + await queryClient.fetchQuery( + cw1WhitelistQueries.adminList(queryClient, { + chainId, + contractAddress, + }) + ) + ).admins + }, + ...options, + }), +} +export interface Cw1WhitelistReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface Cw1WhitelistAdminListQuery + extends Cw1WhitelistReactQuery {} + +export interface Cw1WhitelistAdminsIfCw1WhitelistQuery + extends Cw1WhitelistReactQuery {} diff --git a/packages/state/query/queries/contracts/DaoDaoCore.ts b/packages/state/query/queries/contracts/DaoDaoCore.ts new file mode 100644 index 000000000..2c7bc7c74 --- /dev/null +++ b/packages/state/query/queries/contracts/DaoDaoCore.ts @@ -0,0 +1,918 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { Addr, IndexerDumpState } from '@dao-dao/types' +import { + AdminNominationResponse, + AdminResponse, + ConfigResponse, + Cw20BalancesResponse, + DaoURIResponse, + DumpStateResponse, + GetItemResponse, + InfoResponse, + ListAllSubDaosResponse, + ListItemsResponse, + ListSubDaosResponse, + PauseInfoResponse, + ProposalModulesResponse, + SubDao, + SubDaoWithChainId, + TotalPowerAtHeightResponse, + VotingPowerAtHeightResponse, +} from '@dao-dao/types/contracts/DaoCore.v2' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { DaoCoreV2QueryClient } from '../../../contracts/DaoCore.v2' +import { contractQueries } from '../contract' +import { indexerQueries } from '../indexer' +import { polytoneQueries } from '../polytone' + +export const daoDaoCoreQueryKeys = { + contract: [ + { + contract: 'daoDaoCore', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...daoDaoCoreQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + admin: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'admin', + ...(args && { args }), + }, + ] as const, + adminNomination: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'admin_nomination', + ...(args && { args }), + }, + ] as const, + config: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'config', + ...(args && { args }), + }, + ] as const, + cw20Balances: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'cw20_balances', + ...(args && { args }), + }, + ] as const, + cw20TokenList: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'cw20_token_list', + ...(args && { args }), + }, + ] as const, + cw721TokenList: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'cw721_token_list', + ...(args && { args }), + }, + ] as const, + dumpState: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'dump_state', + ...(args && { args }), + }, + ] as const, + getItem: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'get_item', + ...(args && { args }), + }, + ] as const, + listItems: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'list_items', + ...(args && { args }), + }, + ] as const, + listAllItems: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'list_all_items', + ...(args && { args }), + }, + ] as const, + info: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'info', + ...(args && { args }), + }, + ] as const, + proposalModules: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'proposal_modules', + ...(args && { args }), + }, + ] as const, + activeProposalModules: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'active_proposal_modules', + ...(args && { args }), + }, + ] as const, + proposalModuleCount: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'proposal_module_count', + ...(args && { args }), + }, + ] as const, + pauseInfo: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'pause_info', + ...(args && { args }), + }, + ] as const, + votingModule: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'voting_module', + ...(args && { args }), + }, + ] as const, + listSubDaos: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'list_sub_daos', + ...(args && { args }), + }, + ] as const, + listAllSubDaos: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'list_all_sub_daos', + ...(args && { args }), + }, + ] as const, + daoURI: (contractAddress: string, args?: Record) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'dao_u_r_i', + ...(args && { args }), + }, + ] as const, + votingPowerAtHeight: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'voting_power_at_height', + ...(args && { args }), + }, + ] as const, + totalPowerAtHeight: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...daoDaoCoreQueryKeys.address(contractAddress)[0], + method: 'total_power_at_height', + ...(args && { args }), + }, + ] as const, +} +export const daoDaoCoreQueries = { + admin: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: DaoDaoCoreAdminQuery + ): UseQueryOptions => ({ + queryKey: daoDaoCoreQueryKeys.admin(contractAddress), + queryFn: async () => { + try { + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'daoCore/admin', + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).admin() + }, + ...options, + }), + adminNomination: ({ + chainId, + contractAddress, + options, + }: DaoDaoCoreAdminNominationQuery): UseQueryOptions< + AdminNominationResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.adminNomination(contractAddress), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).adminNomination(), + ...options, + }), + config: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: DaoDaoCoreConfigQuery + ): UseQueryOptions => ({ + queryKey: daoDaoCoreQueryKeys.config(contractAddress), + queryFn: async () => { + try { + const config = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'daoCore/config', + }) + ) + if (config) { + return config + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).config() + }, + ...options, + }), + cw20Balances: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreCw20BalancesQuery): UseQueryOptions< + Cw20BalancesResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.cw20Balances(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).cw20Balances({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + cw20TokenList: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreCw20TokenListQuery): UseQueryOptions< + Addr[], + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.cw20TokenList(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).cw20TokenList({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + cw721TokenList: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreCw721TokenListQuery): UseQueryOptions< + Addr[], + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.cw721TokenList(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).cw721TokenList({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + dumpState: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: DaoDaoCoreDumpStateQuery + ): UseQueryOptions => ({ + queryKey: daoDaoCoreQueryKeys.dumpState(contractAddress), + queryFn: async () => { + try { + const indexerState = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'daoCore/dumpState', + }) + ) + if (indexerState) { + return indexerState + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).dumpState() + }, + ...options, + }), + getItem: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreGetItemQuery): UseQueryOptions< + GetItemResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.getItem(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).getItem({ + key: args.key, + }), + ...options, + }), + listItems: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreListItemsQuery): UseQueryOptions< + ListItemsResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.listItems(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).listItems({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + listAllItems: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreListAllItemsQuery + ): UseQueryOptions => ({ + queryKey: daoDaoCoreQueryKeys.listAllItems(contractAddress, args), + queryFn: async () => { + let items: ListItemsResponse | undefined + + try { + const indexerItems = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'daoCore/listItems', + }) + ) + if (indexerItems) { + items = indexerItems + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + if (!items) { + items = [] + const limit = 30 + while (true) { + const page = await queryClient.fetchQuery( + daoDaoCoreQueries.listItems({ + chainId, + contractAddress, + args: { + limit, + startAfter: items.length + ? items[items.length - 1]?.[0] + : undefined, + }, + }) + ) + if (!page.length) { + break + } + + items.push(...page) + + // If we have less than the limit of items, we've exhausted them. + if (page.length < limit) { + break + } + } + } + + // If we have a prefix, filter out items that don't start with it, and + // then remove the prefix from each key. + if (args?.prefix) { + items = items.flatMap(([key, value]) => + key.startsWith(args.prefix!) + ? [[key.substring(args.prefix!.length), value]] + : [] + ) + } + + return items + }, + ...options, + }), + info: ({ + chainId, + contractAddress, + options, + }: DaoDaoCoreInfoQuery): UseQueryOptions< + InfoResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.info(contractAddress), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).info(), + ...options, + }), + proposalModules: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreProposalModulesQuery): UseQueryOptions< + ProposalModulesResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.proposalModules(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).proposalModules({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + activeProposalModules: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreActiveProposalModulesQuery): UseQueryOptions< + ProposalModulesResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.activeProposalModules(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).activeProposalModules({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + pauseInfo: ({ + chainId, + contractAddress, + options, + }: DaoDaoCorePauseInfoQuery): UseQueryOptions< + PauseInfoResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.pauseInfo(contractAddress), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).pauseInfo(), + ...options, + }), + votingModule: ({ + chainId, + contractAddress, + options, + }: DaoDaoCoreVotingModuleQuery): UseQueryOptions< + Addr, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.votingModule(contractAddress), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).votingModule(), + ...options, + }), + listSubDaos: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreListSubDaosQuery): UseQueryOptions< + ListSubDaosResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.listSubDaos(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).listSubDaos({ + limit: args.limit, + startAfter: args.startAfter, + }), + ...options, + }), + listAllSubDaos: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreListAllSubDaosQuery + ): UseQueryOptions => ({ + queryKey: daoDaoCoreQueryKeys.listAllSubDaos(contractAddress, args), + queryFn: async () => { + let subDaos: SubDao[] | undefined + + try { + const indexerSubDaos = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'daoCore/listSubDaos', + }) + ) + if (indexerSubDaos) { + subDaos = indexerSubDaos + } + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + if (!subDaos) { + subDaos = [] + const limit = 30 + while (true) { + const page = await queryClient.fetchQuery( + daoDaoCoreQueries.listSubDaos({ + chainId, + contractAddress, + args: { + limit, + startAfter: subDaos.length + ? subDaos[subDaos.length - 1]?.addr + : undefined, + }, + }) + ) + if (!page.length) { + break + } + + subDaos.push(...page) + + // If we have less than the limit of subDaos, we've exhausted them. + if (page.length < limit) { + break + } + } + } + + const subDaosWithChainId = ( + await Promise.all( + subDaos.map(async (subDao): Promise => { + const [isDao, isPolytoneProxy] = await Promise.all([ + queryClient.fetchQuery( + contractQueries.isDao(queryClient, { + chainId, + address: subDao.addr, + }) + ), + queryClient.fetchQuery( + contractQueries.isPolytoneProxy(queryClient, { + chainId, + address: subDao.addr, + }) + ), + ]) + + if (isDao) { + // Filter SubDAO by admin if specified. + if (args?.onlyAdmin) { + const admin = await queryClient.fetchQuery( + daoDaoCoreQueries.admin(queryClient, { + chainId, + contractAddress: subDao.addr, + }) + ) + + if (admin !== contractAddress) { + return [] + } + } + + return { + ...subDao, + chainId, + } + } + + // Reverse lookup polytone proxy and verify it's a DAO, as long as + // not filtering by admin, since polytone proxies do not have admins + // and live on other chains. + if (isPolytoneProxy && !args?.onlyAdmin) { + try { + const { chainId: remoteChainId, remoteAddress } = + await queryClient.fetchQuery( + polytoneQueries.reverseLookupProxy(queryClient, { + chainId, + address: subDao.addr, + }) + ) + + return { + chainId: remoteChainId, + addr: remoteAddress, + charter: subDao.charter, + } + } catch (error) { + console.error(error) + } + } + + return [] + }) + ) + ).flat() + + return subDaosWithChainId + }, + ...options, + }), + daoURI: ({ + chainId, + contractAddress, + options, + }: DaoDaoCoreDaoURIQuery): UseQueryOptions< + DaoURIResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.daoURI(contractAddress), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).daoURI(), + ...options, + }), + votingPowerAtHeight: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreVotingPowerAtHeightQuery): UseQueryOptions< + VotingPowerAtHeightResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.votingPowerAtHeight(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).votingPowerAtHeight({ + address: args.address, + height: args.height, + }), + ...options, + }), + totalPowerAtHeight: ({ + chainId, + contractAddress, + args, + options, + }: DaoDaoCoreTotalPowerAtHeightQuery): UseQueryOptions< + TotalPowerAtHeightResponse, + Error, + TData + > => ({ + queryKey: daoDaoCoreQueryKeys.totalPowerAtHeight(contractAddress, args), + queryFn: async () => + new DaoCoreV2QueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).totalPowerAtHeight({ + height: args.height, + }), + ...options, + }), +} + +export interface DaoDaoCoreReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface DaoDaoCoreTotalPowerAtHeightQuery + extends DaoDaoCoreReactQuery { + args: { + height?: number + } +} +export interface DaoDaoCoreVotingPowerAtHeightQuery + extends DaoDaoCoreReactQuery { + args: { + address: string + height?: number + } +} +export interface DaoDaoCoreDaoURIQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreListSubDaosQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreListAllSubDaosQuery + extends DaoDaoCoreReactQuery { + args?: { + /** + * Only include SubDAOs that this DAO is the admin of, meaning this DAO can + * execute on behalf of the SubDAO. Defaults to false. + */ + onlyAdmin?: boolean + } +} +export interface DaoDaoCoreVotingModuleQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCorePauseInfoQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreActiveProposalModulesQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreProposalModulesQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreInfoQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreListItemsQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreListAllItemsQuery + extends DaoDaoCoreReactQuery { + args?: { + /** + * Optionally specify a prefix to filter results by and then remove from + * each returned key. + */ + prefix?: string + } +} +export interface DaoDaoCoreGetItemQuery + extends DaoDaoCoreReactQuery { + args: { + key: string + } +} +export interface DaoDaoCoreDumpStateQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreCw721TokenListQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreCw20TokenListQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreCw20BalancesQuery + extends DaoDaoCoreReactQuery { + args: { + limit?: number + startAfter?: string + } +} +export interface DaoDaoCoreConfigQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreAdminNominationQuery + extends DaoDaoCoreReactQuery {} +export interface DaoDaoCoreAdminQuery + extends DaoDaoCoreReactQuery {} diff --git a/packages/state/query/queries/contracts/PolytoneNote.ts b/packages/state/query/queries/contracts/PolytoneNote.ts new file mode 100644 index 000000000..198adfdf9 --- /dev/null +++ b/packages/state/query/queries/contracts/PolytoneNote.ts @@ -0,0 +1,84 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { NullableString } from '@dao-dao/types/contracts/PolytoneNote' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { PolytoneNoteQueryClient } from '../../../contracts/PolytoneNote' +import { indexerQueries } from '../indexer' + +export const polytoneNoteQueryKeys = { + contract: [ + { + contract: 'polytoneNote', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...polytoneNoteQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + remoteAddress: (contractAddress: string, args?: Record) => + [ + { + ...polytoneNoteQueryKeys.address(contractAddress)[0], + method: 'remote_address', + ...(args && { args }), + }, + ] as const, +} +export const polytoneNoteQueries = { + remoteAddress: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: PolytoneNoteRemoteAddressQuery + ): UseQueryOptions => ({ + queryKey: polytoneNoteQueryKeys.remoteAddress(contractAddress, args), + queryFn: async () => { + try { + return await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'polytone/note/remoteAddress', + args: { + address: args.localAddress, + }, + }) + ) + } catch (error) { + console.error(error) + } + + // If indexer query fails, fallback to contract query. + return new PolytoneNoteQueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).remoteAddress({ + localAddress: args.localAddress, + }) + }, + ...options, + }), +} +export interface PolytoneNoteReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface PolytoneNoteRemoteAddressQuery + extends PolytoneNoteReactQuery { + args: { + localAddress: string + } +} diff --git a/packages/state/query/queries/contracts/PolytoneProxy.ts b/packages/state/query/queries/contracts/PolytoneProxy.ts new file mode 100644 index 000000000..82dd31b44 --- /dev/null +++ b/packages/state/query/queries/contracts/PolytoneProxy.ts @@ -0,0 +1,80 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { Addr } from '@dao-dao/types' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { PolytoneProxyQueryClient } from '../../../contracts/PolytoneProxy' +import { indexerQueries } from '../indexer' + +export const polytoneProxyQueryKeys = { + contract: [ + { + contract: 'polytoneProxy', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...polytoneProxyQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + instantiator: (contractAddress: string, args?: Record) => + [ + { + ...polytoneProxyQueryKeys.address(contractAddress)[0], + method: 'instantiator', + ...(args && { args }), + }, + ] as const, +} +export const polytoneProxyQueries = { + instantiator: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: PolytoneProxyInstantiatorQuery + ): UseQueryOptions => ({ + queryKey: polytoneProxyQueryKeys.instantiator(contractAddress), + queryFn: async () => { + let indexerNonExistent = false + try { + const instantiator = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'polytone/proxy/instantiator', + }) + ) + if (instantiator) { + return instantiator + } else { + indexerNonExistent = true + } + } catch (error) { + console.error(error) + } + + if (indexerNonExistent) { + throw new Error('Instantiator not found') + } + + // If indexer query fails, fallback to contract query. + return new PolytoneProxyQueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).instantiator() + }, + ...options, + }), +} +export interface PolytoneProxyReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface PolytoneProxyInstantiatorQuery + extends PolytoneProxyReactQuery {} diff --git a/packages/state/query/queries/contracts/PolytoneVoice.ts b/packages/state/query/queries/contracts/PolytoneVoice.ts new file mode 100644 index 000000000..8a424f395 --- /dev/null +++ b/packages/state/query/queries/contracts/PolytoneVoice.ts @@ -0,0 +1,95 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { SenderInfo } from '@dao-dao/types/contracts/PolytoneVoice' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { PolytoneVoiceQueryClient } from '../../../contracts/PolytoneVoice' +import { indexerQueries } from '../indexer' + +export const polytoneVoiceQueryKeys = { + contract: [ + { + contract: 'polytoneVoice', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...polytoneVoiceQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + senderInfoForProxy: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...polytoneVoiceQueryKeys.address(contractAddress)[0], + method: 'sender_info_for_proxy', + ...(args && { args }), + }, + ] as const, +} +export const polytoneVoiceQueries = { + senderInfoForProxy: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + args, + options, + }: PolytoneVoiceSenderInfoForProxyQuery + ): UseQueryOptions => ({ + queryKey: polytoneVoiceQueryKeys.senderInfoForProxy(contractAddress, args), + queryFn: async () => { + let indexerNonExistent = false + try { + const senderInfo = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'polytone/voice/senderInfoForProxy', + args: { + address: args.proxy, + }, + }) + ) + if (senderInfo) { + return senderInfo + } else { + indexerNonExistent = true + } + } catch (error) { + console.error(error) + } + + if (indexerNonExistent) { + throw new Error('Sender info not found') + } + + // If indexer query fails, fallback to contract query. + return new PolytoneVoiceQueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).senderInfoForProxy(args) + }, + ...options, + }), +} +export interface PolytoneVoiceReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface PolytoneVoiceSenderInfoForProxyQuery + extends PolytoneVoiceReactQuery { + args: { + proxy: string + } +} diff --git a/packages/state/query/queries/contracts/index.ts b/packages/state/query/queries/contracts/index.ts new file mode 100644 index 000000000..a451496e3 --- /dev/null +++ b/packages/state/query/queries/contracts/index.ts @@ -0,0 +1,6 @@ +export * from './Cw1Whitelist' +export * from './DaoDaoCore' +export * from './PolytoneNote' +export * from './PolytoneProxy' +export * from './PolytoneVoice' +export * from './votingModule' diff --git a/packages/state/query/queries/contracts/votingModule.ts b/packages/state/query/queries/contracts/votingModule.ts new file mode 100644 index 000000000..404d30f32 --- /dev/null +++ b/packages/state/query/queries/contracts/votingModule.ts @@ -0,0 +1,84 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { + ActiveThreshold, + ActiveThresholdResponse, +} from '@dao-dao/types/contracts/common' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { indexerQueries } from '../indexer' + +/** + * Fetch whether or not the voting module is active. + */ +export const fetchVotingModuleIsActive = async ({ + chainId, + address, +}: { + chainId: string + address: string +}): Promise => + (await cosmWasmClientRouter.connect(chainId)).queryContractSmart(address, { + is_active: {}, + }) + +/** + * Fetch whether or not the voting module is active. + */ +export const fetchVotingModuleActiveThreshold = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + try { + return { + active_threshold: await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: address, + formula: 'daoVoting/activeThreshold', + }) + ), + } + } catch (error) { + console.error(error) + } + + // If indexer fails, fallback to querying chain. + return (await cosmWasmClientRouter.connect(chainId)).queryContractSmart( + address, + { + active_threshold: {}, + } + ) +} + +/** + * Common voting module queries. + */ +export const votingModuleQueries = { + /** + * Fetch whether or not the voting module is active. + */ + isActive: (options: Parameters[0]) => + queryOptions({ + queryKey: ['votingModule', 'isActive', options], + queryFn: () => fetchVotingModuleIsActive(options), + }), + /** + * Fetch the active threshold. + */ + activeThresold: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['votingModule', 'activeThresold', options], + queryFn: () => fetchVotingModuleActiveThreshold(queryClient, options), + }), +} diff --git a/packages/state/query/queries/dao.ts b/packages/state/query/queries/dao.ts new file mode 100644 index 000000000..9f1942ed0 --- /dev/null +++ b/packages/state/query/queries/dao.ts @@ -0,0 +1,63 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { AmountWithTimestamp, DaoSource } from '@dao-dao/types' +import { + COMMUNITY_POOL_ADDRESS_PLACEHOLDER, + getSupportedChainConfig, + isConfiguredChainName, +} from '@dao-dao/utils' + +import { indexerQueries } from './indexer' + +/** + * Fetch a DAO's TVL. + */ +export const fetchDaoTvl = async ( + queryClient: QueryClient, + { chainId, coreAddress }: DaoSource +): Promise => { + // Native chain x/gov module. + if (isConfiguredChainName(chainId, coreAddress)) { + coreAddress = + // Use real gov DAO's address if exists. + getSupportedChainConfig(chainId)?.govContractAddress || + COMMUNITY_POOL_ADDRESS_PLACEHOLDER + } + + const timestamp = new Date() + + const { total: amount } = (await queryClient.fetchQuery( + indexerQueries.snapper<{ total: number }>({ + query: 'daodao-tvl', + parameters: { + chainId, + address: coreAddress, + }, + }) + )) || { + total: NaN, + } + + return { + amount, + timestamp, + } +} + +export const daoQueries = { + /** + * Fetch featured DAOs. + */ + listFeatured: () => + indexerQueries.snapper({ + query: 'daodao-featured-daos', + }), + /** + * Fetch a DAO's TVL. + */ + tvl: (queryClient: QueryClient, options: Parameters[1]) => + queryOptions({ + queryKey: ['dao', 'tvl', options], + queryFn: () => fetchDaoTvl(queryClient, options), + }), +} diff --git a/packages/state/query/queries/index.ts b/packages/state/query/queries/index.ts new file mode 100644 index 000000000..cee7cc5a6 --- /dev/null +++ b/packages/state/query/queries/index.ts @@ -0,0 +1,9 @@ +export * from './contracts' + +export * from './account' +export * from './chain' +export * from './contract' +export * from './dao' +export * from './indexer' +export * from './polytone' +export * from './profile' diff --git a/packages/state/query/queries/indexer.ts b/packages/state/query/queries/indexer.ts new file mode 100644 index 000000000..e84a3fa0c --- /dev/null +++ b/packages/state/query/queries/indexer.ts @@ -0,0 +1,148 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { IndexerFormulaType } from '@dao-dao/types' + +import { + QueryIndexerOptions, + QuerySnapperOptions, + queryIndexer, + queryIndexerUpStatus, + querySnapper, +} from '../../indexer' + +/** + * Check whether or not the indexer is caught up. + */ +export const isIndexerCaughtUp = async ({ + chainId, +}: { + chainId: string +}): Promise => (await queryIndexerUpStatus({ chainId })).caughtUp + +export type FetchIndexerQueryOptions = QueryIndexerOptions & { + /** + * If there is no fallback query available, this will still query even if + * indexer is behind. Defaults to false. + */ + noFallback?: boolean +} + +/** + * Fetch indexer query, unless the indexer is behind and there is a fallback, in + * which case it errors. + */ +export const fetchIndexerQuery = async ( + queryClient: QueryClient, + { noFallback, ...options }: FetchIndexerQueryOptions +): Promise => { + // If the indexer is behind and either there's a fallback or we're on the + // server, return null to make the caller use the fallback. Throw error if no + // fallback and on client. + if (!noFallback) { + const isCaughtUp = await queryClient.fetchQuery( + indexerQueries.isCaughtUp({ chainId: options.chainId }) + ) + + if (!isCaughtUp && typeof window !== 'undefined') { + throw new Error('Indexer is behind and no fallback is available') + } + } + + // Replace undefined responses with null since react-query and static props + // can't serialize undefined. + return (await queryIndexer(options)) ?? null +} + +export const indexerQueries = { + /** + * Check whether or not the indexer is caught up. + */ + isCaughtUp: (options: Parameters[0]) => + queryOptions({ + queryKey: ['indexer', 'isCaughtUp', options], + queryFn: () => isIndexerCaughtUp(options), + }), + /** + * Fetch indexer query, unless the indexer is behind and there is a fallback. + */ + query: ( + queryClient: QueryClient, + options: FetchIndexerQueryOptions + ) => + queryOptions({ + queryKey: ['indexer', 'query', options], + queryFn: () => fetchIndexerQuery(queryClient, options), + }), + /** + * Fetch indexer query, unless the indexer is behind and there is a fallback. + */ + queryContract: ( + queryClient: QueryClient, + { + contractAddress: address, + ...options + }: Omit & { + contractAddress: string + } + ) => + indexerQueries.query(queryClient, { + ...options, + type: IndexerFormulaType.Contract, + address, + }), + /** + * Fetch indexer query, unless the indexer is behind and there is a fallback. + */ + queryGeneric: ( + queryClient: QueryClient, + options: Omit + ) => + indexerQueries.query(queryClient, { + ...options, + type: IndexerFormulaType.Generic, + }), + /** + * Fetch indexer query, unless the indexer is behind and there is a fallback. + */ + queryValidator: ( + queryClient: QueryClient, + { + validatorOperatorAddress: address, + ...options + }: Omit & { + validatorOperatorAddress: string + } + ) => + indexerQueries.query(queryClient, { + ...options, + type: IndexerFormulaType.Validator, + address, + }), + /** + * Fetch indexer query, unless the indexer is behind and there is a fallback. + */ + queryWallet: ( + queryClient: QueryClient, + { + walletAddress: address, + ...options + }: Omit & { + walletAddress: string + } + ) => + indexerQueries.query(queryClient, { + ...options, + type: IndexerFormulaType.Wallet, + address, + }), + /** + * Fetch query from Snapper. + */ + snapper: (options: QuerySnapperOptions) => + queryOptions({ + queryKey: ['indexer', 'snapper', options], + // Replace undefined responses with null since react-query and static + // props can't serialize undefined. + queryFn: async () => (await querySnapper(options)) ?? null, + }), +} diff --git a/packages/state/query/queries/polytone.ts b/packages/state/query/queries/polytone.ts new file mode 100644 index 000000000..2da750b3f --- /dev/null +++ b/packages/state/query/queries/polytone.ts @@ -0,0 +1,157 @@ +import { QueryClient, queryOptions } from '@tanstack/react-query' + +import { PolytoneProxies } from '@dao-dao/types' +import { + POLYTONE_CONFIG_PER_CHAIN, + getSupportedChainConfig, + polytoneNoteProxyMapToChainIdMap, +} from '@dao-dao/utils' + +import { polytoneProxyQueries, polytoneVoiceQueries } from './contracts' +import { polytoneNoteQueries } from './contracts/PolytoneNote' +import { indexerQueries } from './indexer' + +/** + * Fetch polytone proxies for an account. + */ +export const fetchPolytoneProxies = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + // Map from polytone note contract to remote proxy address. + try { + return polytoneNoteProxyMapToChainIdMap( + chainId, + await queryClient.fetchQuery( + indexerQueries.queryWallet(queryClient, { + chainId, + walletAddress: address, + formula: 'polytone/proxies', + }) + ) + ) + } catch (error) { + console.error(error) + } + + // Fallback to contract query if indexer fails. + + // Get polytone notes on this chain. + const polytoneConnections = Object.entries( + getSupportedChainConfig(chainId)?.polytone || {} + ) + + // Fetch remote address for this address on all potential polytone + // connections, filtering out the nonexistent ones, and turn back into map of + // chain to proxy. + const proxies: PolytoneProxies = Object.fromEntries( + ( + await Promise.all( + polytoneConnections.map(async ([proxyChainId, { note }]) => { + const proxy = await queryClient.fetchQuery( + polytoneNoteQueries.remoteAddress(queryClient, { + chainId, + contractAddress: note, + args: { + localAddress: address, + }, + }) + ) + + // Null respones get filtered out. + return proxy ? ([proxyChainId, proxy] as const) : undefined + }) + ) + ).filter(Boolean) as [string, string][] + ) + + return proxies +} + +/** + * Given a polytone proxy, fetch the source chain, remote address, and polytone + * note. + */ +export const reverseLookupPolytoneProxy = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise<{ + chainId: string + remoteAddress: string + note: string +}> => { + const voice = await queryClient.fetchQuery( + polytoneProxyQueries.instantiator(queryClient, { + chainId, + contractAddress: address, + }) + ) + + // Get sender info for this voice. + const senderInfo = await queryClient.fetchQuery( + polytoneVoiceQueries.senderInfoForProxy(queryClient, { + chainId, + contractAddress: voice, + args: { + proxy: address, + }, + }) + ) + + // Get source polytone connection where the note lives for this voice. + const srcPolytoneInfo = POLYTONE_CONFIG_PER_CHAIN.find(([, config]) => + Object.entries(config).some( + ([destChainId, connection]) => + destChainId === chainId && + connection.voice === voice && + connection.remoteConnection === senderInfo.connection_id + ) + ) + if (!srcPolytoneInfo) { + throw new Error('Could not find source polytone connection') + } + + return { + chainId: srcPolytoneInfo[0], + remoteAddress: senderInfo.remote_sender, + note: srcPolytoneInfo[1][chainId].note, + } +} + +export const polytoneQueries = { + /** + * Fetch polytone proxies for an account. + */ + proxies: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['polytone', 'proxies', options], + queryFn: () => fetchPolytoneProxies(queryClient, options), + }), + /** + * Given a polytone proxy, fetch the source chain, remote address, and + * polytone note. + */ + reverseLookupProxy: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['polytone', 'reverseLookupProxy', options], + queryFn: () => reverseLookupPolytoneProxy(queryClient, options), + }), +} diff --git a/packages/state/query/queries/profile.ts b/packages/state/query/queries/profile.ts new file mode 100644 index 000000000..9c8e5be68 --- /dev/null +++ b/packages/state/query/queries/profile.ts @@ -0,0 +1,338 @@ +import { + QueryClient, + UseQueryOptions, + queryOptions, + skipToken, +} from '@tanstack/react-query' + +import { + ChainId, + PfpkProfile, + ResolvedProfile, + UnifiedProfile, +} from '@dao-dao/types' +import { + MAINNET, + PFPK_API_BASE, + STARGAZE_NAMES_CONTRACT, + cosmWasmClientRouter, + getChainForChainId, + imageUrlFromStargazeIndexerNft, + makeEmptyPfpkProfile, + makeEmptyUnifiedProfile, + processError, + toBech32Hash, + transformBech32Address, +} from '@dao-dao/utils' + +import { stargazeIndexerClient, stargazeTokenQuery } from '../../graphql' + +/** + * Fetch unified profile information for any wallet. + */ +export const fetchProfileInfo = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const profile = makeEmptyUnifiedProfile(chainId, address) + if (!address) { + return profile + } + + const pfpkProfile = await queryClient.fetchQuery( + profileQueries.pfpk({ + address, + }) + ) + // Copy PFPK profile info into unified profile. + profile.uuid = pfpkProfile.uuid + profile.nonce = pfpkProfile.nonce + profile.name = pfpkProfile.name + profile.nft = pfpkProfile.nft + profile.chains = pfpkProfile.chains + + // Use profile address for Stargaze if set, falling back to transforming the + // address (which is unreliable due to different chains using different HD + // paths). + const stargazeAddress = + profile.chains[ChainId.StargazeMainnet]?.address || + transformBech32Address(address, ChainId.StargazeMainnet) + + // Load Stargaze name as backup if no PFPK name set. + if (!profile.name) { + const stargazeName = await queryClient + .fetchQuery( + profileQueries.stargazeName({ + address: stargazeAddress, + }) + ) + .catch(() => null) + if (stargazeName) { + profile.name = + stargazeName + '.' + getChainForChainId(chainId).bech32_prefix + profile.nameSource = 'stargaze' + } + } + + // Set `imageUrl` to PFPK image, defaulting to fallback image. + profile.imageUrl = pfpkProfile?.nft?.imageUrl || profile.backupImageUrl + + // Load Stargaze name image if no PFPK image. + if (!pfpkProfile?.nft?.imageUrl) { + const stargazeNameImage = await queryClient + .fetchQuery( + profileQueries.stargazeNameImage(queryClient, { + address: stargazeAddress, + }) + ) + .catch(() => null) + if (stargazeNameImage) { + profile.imageUrl = stargazeNameImage + } + } + + return profile +} + +/** + * Fetch PFPK profile information for any wallet. + */ +export const fetchPfpkProfileInfo = async ({ + bech32Hash, +}: { + bech32Hash: string +}): Promise => { + if (!bech32Hash) { + return makeEmptyPfpkProfile() + } + + try { + const response = await fetch(PFPK_API_BASE + `/bech32/${bech32Hash}`) + if (response.ok) { + return await response.json() + } else { + console.error(await response.json().catch(() => response.statusText)) + } + } catch (err) { + console.error(err) + } + + return makeEmptyPfpkProfile() +} + +/** + * Fetch Stargaze name for a wallet adderss. + */ +export const fetchStargazeName = async ({ + address, +}: { + address: string +}): Promise => { + if (!address) { + return null + } + + const client = await cosmWasmClientRouter.connect( + MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet + ) + + try { + return await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { + name: { address }, + }) + } catch {} + + return null +} + +/** + * Fetch Stargaze name's image associated an address. + */ +export const fetchStargazeNameImage = async ( + queryClient: QueryClient, + { + address, + }: { + address: string + } +): Promise => { + const name = await queryClient.fetchQuery( + profileQueries.stargazeName({ address }) + ) + if (!name) { + return null + } + + const chainId = MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet + const client = await cosmWasmClientRouter.connect(chainId) + + // Get NFT associated with name. + let response + try { + response = await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { + image_n_f_t: { name }, + }) + } catch { + return null + } + + if (!response) { + return null + } + + // If NFT exists, get image associated with NFT. + try { + const { data } = await stargazeIndexerClient.query({ + query: stargazeTokenQuery, + variables: { + collectionAddr: response.collection, + tokenId: response.token_id, + }, + }) + if (data?.token) { + return imageUrlFromStargazeIndexerNft(data.token) || null + } + } catch (err) { + console.error(err) + } + + return null +} + +/** + * Search for profiles by name prefix. + */ +export const searchProfilesByNamePrefix = async ({ + chainId, + namePrefix, +}: { + chainId: string + namePrefix: string +}): Promise => { + if (namePrefix.length < 3) { + return [] + } + + // Load profiles from PFPK API. + let profiles: ResolvedProfile[] = [] + try { + const response = await fetch( + PFPK_API_BASE + `/search/${chainId}/${namePrefix}` + ) + if (response.ok) { + const { profiles: _profiles } = (await response.json()) as { + profiles: ResolvedProfile[] + } + profiles = _profiles + } else { + console.error(await response.json()) + } + } catch (err) { + console.error(processError(err)) + } + + return profiles +} + +export const profileQueries = { + /** + * Fetch unified profile. + */ + unified: ( + queryClient: QueryClient, + // If undefined, query will be disabled. + options?: Parameters[1] + ) => + queryOptions({ + queryKey: [ + { + category: 'profile', + name: 'unified', + options: options && { + ...options, + // Add this to match pfpk query key so we can invalidate and thus + // refetch both at once. + bech32Hash: toBech32Hash(options.address), + }, + }, + ], + queryFn: options + ? () => fetchProfileInfo(queryClient, options) + : skipToken, + }), + /** + * Fetch PFPK profile. + */ + pfpk: ( + /** + * Redirects address queries to bech32 hash queries. + * + * If undefined, query will be disabled. + */ + options?: { address: string } | { bech32Hash: string } + ): UseQueryOptions< + PfpkProfile, + Error, + PfpkProfile, + [ + { + category: 'profile' + name: 'pfpk' + options: { bech32Hash: string } | undefined + } + ] + > => + // Redirect address queries to bech32 hash queries. + options && 'address' in options + ? profileQueries.pfpk({ + bech32Hash: toBech32Hash(options.address), + }) + : queryOptions({ + queryKey: [ + { + category: 'profile', + name: 'pfpk', + options, + }, + ], + queryFn: options ? () => fetchPfpkProfileInfo(options) : skipToken, + }), + /** + * Fetch Stargaze name for a wallet adderss. + */ + stargazeName: (options: Parameters[0]) => + queryOptions({ + queryKey: ['profile', 'stargazeName', options], + queryFn: () => fetchStargazeName(options), + }), + /** + * Fetch Stargaze name's image associated an address. + */ + stargazeNameImage: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['profile', 'stargazeNameImage', options], + queryFn: () => fetchStargazeNameImage(queryClient, options), + }), + /** + * Search for profiles by name prefix. + */ + searchByNamePrefix: ( + /** + * If undefined, query will be disabled. + */ + options?: Parameters[0] + ) => + queryOptions({ + queryKey: ['profile', 'searchByNamePrefix', options], + queryFn: options ? () => searchProfilesByNamePrefix(options) : skipToken, + }), +} diff --git a/packages/state/recoil/atoms/misc.ts b/packages/state/recoil/atoms/misc.ts index dceb2dbc2..e7d9d8571 100644 --- a/packages/state/recoil/atoms/misc.ts +++ b/packages/state/recoil/atoms/misc.ts @@ -1,3 +1,4 @@ +import { QueryClient } from '@tanstack/react-query' import { atom } from 'recoil' import { PageHeaderProps, Web3AuthPrompt } from '@dao-dao/types' @@ -62,3 +63,9 @@ export const pageHeaderPropsAtom = atom({ key: 'pageHeaderProps', default: {}, }) + +// Store query client in Recoil atom so it's accessible from Recoil selectors +// while we migrate from Recoil to React Query. +export const queryClientAtom = atom({ + key: 'queryClient', +}) diff --git a/packages/state/recoil/atoms/refresh.ts b/packages/state/recoil/atoms/refresh.ts index 82aae626b..37e9493f9 100644 --- a/packages/state/recoil/atoms/refresh.ts +++ b/packages/state/recoil/atoms/refresh.ts @@ -50,13 +50,6 @@ export const refreshWalletStargazeNftsAtom = atomFamily({ default: 0, }) -// Change this to refresh the profile for a wallet. The argument is the address' -// bech32 data hash. -export const refreshWalletProfileAtom = atomFamily({ - key: 'refreshWalletProfile', - default: 0, -}) - // Change this to refresh native token staking info for the given address. export const refreshNativeTokenStakingInfoAtom = atomFamily< number, diff --git a/packages/state/recoil/selectors/chain.ts b/packages/state/recoil/selectors/chain.ts index 8bc5ae70f..c249e1e4b 100644 --- a/packages/state/recoil/selectors/chain.ts +++ b/packages/state/recoil/selectors/chain.ts @@ -31,15 +31,6 @@ import { ValidatorSlash, WithChainId, } from '@dao-dao/types' -import { - cosmos, - cosmwasm, - ibc, - juno, - neutron, - noble, - osmosis, -} from '@dao-dao/types/protobuf' import { ModuleAccount } from '@dao-dao/types/protobuf/codegen/cosmos/auth/v1beta1/auth' import { Metadata } from '@dao-dao/types/protobuf/codegen/cosmos/bank/v1beta1/bank' import { DecCoin } from '@dao-dao/types/protobuf/codegen/cosmos/base/v1beta1/coin' @@ -58,16 +49,20 @@ import { Fee as NeutronFee } from '@dao-dao/types/protobuf/codegen/neutron/feere import { Params as NobleTariffParams } from '@dao-dao/types/protobuf/codegen/tariff/params' import { MAINNET, - addressIsModule, cosmWasmClientRouter, + cosmosProtoRpcClientRouter, cosmosSdkVersionIs46OrHigher, cosmosSdkVersionIs47OrHigher, cosmosValidatorToValidator, + cosmwasmProtoRpcClientRouter, decodeGovProposal, getAllRpcResponse, getNativeTokenForChainId, - getRpcForChainId, - retry, + ibcProtoRpcClientRouter, + junoProtoRpcClientRouter, + neutronProtoRpcClientRouter, + nobleProtoRpcClientRouter, + osmosisProtoRpcClientRouter, stargateClientRouter, } from '@dao-dao/utils' @@ -93,14 +88,7 @@ export const stargateClientForChainSelector = selectorFamily< string >({ key: 'stargateClientForChain', - get: (chainId) => async () => - retry( - 10, - async (attempt) => - await stargateClientRouter.connect( - getRpcForChainId(chainId, attempt - 1) - ) - ), + get: (chainId) => async () => await stargateClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) @@ -109,106 +97,50 @@ export const cosmWasmClientForChainSelector = selectorFamily< string >({ key: 'cosmWasmClientForChain', - get: (chainId) => async () => - retry( - 10, - async (attempt) => - await cosmWasmClientRouter.connect( - getRpcForChainId(chainId, attempt - 1) - ) - ), + get: (chainId) => async () => await cosmWasmClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) export const cosmosRpcClientForChainSelector = selectorFamily({ key: 'cosmosRpcClientForChain', get: (chainId: string) => async () => - retry( - 10, - async (attempt) => - ( - await cosmos.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(chainId, attempt - 1), - }) - ).cosmos - ), + await cosmosProtoRpcClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) export const ibcRpcClientForChainSelector = selectorFamily({ key: 'ibcRpcClientForChain', get: (chainId: string) => async () => - retry( - 10, - async (attempt) => - ( - await ibc.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(chainId, attempt - 1), - }) - ).ibc - ), + await ibcProtoRpcClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) export const cosmwasmRpcClientForChainSelector = selectorFamily({ key: 'cosmwasmRpcClientForChain', get: (chainId: string) => async () => - retry( - 10, - async (attempt) => - ( - await cosmwasm.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(chainId, attempt - 1), - }) - ).cosmwasm - ), + await cosmwasmProtoRpcClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) export const osmosisRpcClientForChainSelector = selectorFamily({ key: 'osmosisRpcClientForChain', get: (chainId: string) => async () => - retry( - 10, - async (attempt) => - ( - await osmosis.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(chainId, attempt - 1), - }) - ).osmosis - ), + await osmosisProtoRpcClientRouter.connect(chainId), dangerouslyAllowMutability: true, }) export const nobleRpcClientSelector = selector({ key: 'nobleRpcClient', get: async () => - retry( - 10, - async (attempt) => - ( - await noble.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(ChainId.NobleMainnet, attempt - 1), - }) - ).noble - ), + await nobleProtoRpcClientRouter.connect(ChainId.NobleMainnet), dangerouslyAllowMutability: true, }) export const neutronRpcClientSelector = selector({ key: 'neutronRpcClient', get: async () => - retry( - 10, - async (attempt) => - ( - await neutron.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId( - MAINNET ? ChainId.NeutronMainnet : ChainId.NeutronTestnet, - attempt - 1 - ), - }) - ).neutron + await neutronProtoRpcClientRouter.connect( + MAINNET ? ChainId.NeutronMainnet : ChainId.NeutronTestnet ), dangerouslyAllowMutability: true, }) @@ -216,14 +148,8 @@ export const neutronRpcClientSelector = selector({ export const junoRpcClientSelector = selector({ key: 'junoRpcClient', get: async () => - retry( - 10, - async (attempt) => - ( - await juno.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(ChainId.JunoMainnet, attempt - 1), - }) - ).juno + await junoProtoRpcClientRouter.connect( + MAINNET ? ChainId.JunoMainnet : ChainId.JunoTestnet ), dangerouslyAllowMutability: true, }) @@ -1311,24 +1237,6 @@ export const moduleNameForAddressSelector = selectorFamily< }, }) -// Check whether or not the address is a module account. -export const addressIsModuleSelector = selectorFamily< - boolean, - WithChainId<{ - address: string - // If passed, check if it is this specific module. - moduleName?: string - }> ->({ - key: 'addressIsModule', - get: - ({ chainId, address, moduleName }) => - async ({ get }) => { - const client = get(cosmosRpcClientForChainSelector(chainId)) - return await addressIsModule(client, address, moduleName) - }, -}) - // Get bonded and unbonded tokens. Bonded tokens represent all possible // governance voting power. export const chainStakingPoolSelector = selectorFamily>({ diff --git a/packages/state/recoil/selectors/contracts/DaoCore.v2.ts b/packages/state/recoil/selectors/contracts/DaoCore.v2.ts index 80df9abfe..a730a7f46 100644 --- a/packages/state/recoil/selectors/contracts/DaoCore.v2.ts +++ b/packages/state/recoil/selectors/contracts/DaoCore.v2.ts @@ -1418,11 +1418,12 @@ export const polytoneProxiesSelector = selectorFamily< ) } + // Fallback to contract query if indexer fails. + // Get polytone notes on this chain. const polytoneConnections = getSupportedChainConfig(queryClientParams.chainId)?.polytone || {} - // Fallback to contract query if indexer fails. return Object.entries(polytoneConnections) .map(([chainId, { note }]) => ({ chainId, diff --git a/packages/state/recoil/selectors/dao.ts b/packages/state/recoil/selectors/dao.ts index 9184949d7..f58424162 100644 --- a/packages/state/recoil/selectors/dao.ts +++ b/packages/state/recoil/selectors/dao.ts @@ -4,19 +4,15 @@ import { ContractVersion, ContractVersionInfo, DaoDropdownInfo, - DaoParentInfo, Feature, LazyDaoCardProps, WithChainId, } from '@dao-dao/types' -import { ConfigResponse as CwCoreV1ConfigResponse } from '@dao-dao/types/contracts/CwCore.v1' -import { ConfigResponse as DaoCoreV2ConfigResponse } from '@dao-dao/types/contracts/DaoCore.v2' import { DAO_CORE_CONTRACT_NAMES, INACTIVE_DAO_NAMES, VETOABLE_DAOS_ITEM_KEY_PREFIX, getChainGovernanceDaoDescription, - getConfiguredChainConfig, getDisplayNameForChainId, getFallbackImage, getImageUrlForChainId, @@ -26,12 +22,7 @@ import { parseContractVersion, } from '@dao-dao/utils' -import { addressIsModuleSelector } from './chain' -import { - contractInfoSelector, - contractVersionSelector, - isDaoSelector, -} from './contract' +import { contractInfoSelector, contractVersionSelector } from './contract' import { DaoCoreV2Selectors } from './contracts' import { queryContractIndexerSelector } from './indexer' @@ -223,118 +214,6 @@ export const daoVetoableDaosSelector = selectorFamily< }), }) -/** - * Attempt to fetch the info needed to describe a parent DAO. Returns undefined - * if not a DAO nor the chain gov module account. - */ -export const daoParentInfoSelector = selectorFamily< - DaoParentInfo | undefined, - WithChainId<{ - parentAddress: string - /** - * To determine if the parent has registered the child, pass the child. This - * will set `registeredSubDao` appropriately. Otherwise, if undefined, - * `registeredSubDao` will be set to false. - */ - childAddress?: string - }> ->({ - key: 'daoParentInfo', - get: - ({ chainId, parentAddress, childAddress }) => - ({ get }) => { - // If address is a DAO contract... - if ( - get( - isDaoSelector({ - chainId, - address: parentAddress, - }) - ) - ) { - const parentAdmin = get( - DaoCoreV2Selectors.adminSelector({ - chainId, - contractAddress: parentAddress, - params: [], - }) - ) - const { - info: { version }, - } = get( - contractInfoSelector({ - chainId, - contractAddress: parentAddress, - }) - ) - const parentVersion = parseContractVersion(version) - - if (parentVersion) { - const { - name, - image_url, - }: CwCoreV1ConfigResponse | DaoCoreV2ConfigResponse = get( - // Both v1 and v2 have a config query. - DaoCoreV2Selectors.configSelector({ - chainId, - contractAddress: parentAddress, - params: [], - }) - ) - - // Check if parent has registered the child DAO as a SubDAO. - const registeredSubDao = - childAddress && - isFeatureSupportedByVersion(Feature.SubDaos, parentVersion) - ? get( - DaoCoreV2Selectors.listAllSubDaosSelector({ - contractAddress: parentAddress, - chainId, - }) - ).some(({ addr }) => addr === childAddress) - : false - - return { - chainId, - coreAddress: parentAddress, - coreVersion: parentVersion, - name, - imageUrl: image_url || getFallbackImage(parentAddress), - admin: parentAdmin ?? '', - registeredSubDao, - } - } - - // If address is the chain's x/gov module account... - } else if ( - get( - addressIsModuleSelector({ - chainId, - address: parentAddress, - moduleName: 'gov', - }) - ) - ) { - const chainConfig = getConfiguredChainConfig(chainId) - return ( - chainConfig && { - chainId, - coreAddress: chainConfig.name, - coreVersion: ContractVersion.Gov, - name: getDisplayNameForChainId(chainId), - imageUrl: getImageUrlForChainId(chainId), - admin: '', - registeredSubDao: - !!childAddress && - !!getSupportedChainConfig(chainId)?.subDaos?.includes( - childAddress - ), - } - ) - } - }, -}) - /** * Retrieve all potential SubDAOs of the DAO from the indexer. */ diff --git a/packages/state/recoil/selectors/index.ts b/packages/state/recoil/selectors/index.ts index a7820615c..55c52c7d5 100644 --- a/packages/state/recoil/selectors/index.ts +++ b/packages/state/recoil/selectors/index.ts @@ -13,7 +13,6 @@ export * from './indexer' export * from './misc' export * from './nft' export * from './osmosis' -export * from './profile' export * from './proposal' export * from './skip' export * from './token' diff --git a/packages/state/recoil/selectors/indexer.ts b/packages/state/recoil/selectors/indexer.ts index c2f0889b9..5f0a541bc 100644 --- a/packages/state/recoil/selectors/indexer.ts +++ b/packages/state/recoil/selectors/indexer.ts @@ -13,7 +13,6 @@ import { WEB_SOCKET_PUSHER_APP_KEY, WEB_SOCKET_PUSHER_HOST, WEB_SOCKET_PUSHER_PORT, - getSupportedChainConfig, getSupportedChains, } from '@dao-dao/utils' @@ -403,38 +402,3 @@ export const indexerMeilisearchClientSelector = selector({ get: () => loadMeilisearchClient(), dangerouslyAllowMutability: true, }) - -/** - * Featured DAOs on a given chain. - */ -export const indexerFeaturedDaosSelector = selectorFamily< - { - address: string - order: number - }[], - string ->({ - key: 'indexerFeaturedDaos', - get: - (chainId) => - async ({ get }) => { - const config = getSupportedChainConfig(chainId) - if (!config) { - return [] - } - - const featuredDaos: { - address: string - order: number - }[] = - get( - queryGenericIndexerSelector({ - chainId, - formula: 'featuredDaos', - noFallback: true, - }) - ) || [] - - return featuredDaos - }, -}) diff --git a/packages/state/recoil/selectors/profile.ts b/packages/state/recoil/selectors/profile.ts deleted file mode 100644 index dc9ccdedd..000000000 --- a/packages/state/recoil/selectors/profile.ts +++ /dev/null @@ -1,309 +0,0 @@ -import uniq from 'lodash.uniq' -import { noWait, selectorFamily, waitForAll } from 'recoil' - -import { - ChainId, - PfpkProfile, - ResolvedProfile, - UnifiedProfile, - WithChainId, -} from '@dao-dao/types' -import { - EMPTY_PFPK_PROFILE, - MAINNET, - PFPK_API_BASE, - STARGAZE_NAMES_CONTRACT, - getChainForChainId, - makeEmptyUnifiedProfile, - objectMatchesStructure, - processError, - toBech32Hash, - transformBech32Address, -} from '@dao-dao/utils' - -import { refreshWalletProfileAtom } from '../atoms/refresh' -import { cosmWasmClientForChainSelector } from './chain' -import { nftCardInfoSelector } from './nft' - -export const searchProfilesByNamePrefixSelector = selectorFamily< - ResolvedProfile[], - WithChainId<{ namePrefix: string }> ->({ - key: 'searchProfilesByNamePrefix', - get: - ({ namePrefix, chainId }) => - async ({ get }) => { - if (namePrefix.length < 3) { - return [] - } - - // Load profiles from PFPK API. - let profiles: ResolvedProfile[] = [] - try { - const response = await fetch( - PFPK_API_BASE + `/search/${chainId}/${namePrefix}` - ) - if (response.ok) { - const { profiles: _profiles } = (await response.json()) as { - profiles: ResolvedProfile[] - } - profiles = _profiles - } else { - console.error(await response.json()) - } - } catch (err) { - console.error(processError(err)) - } - - // Add refresher dependencies. - if (profiles.length > 0) { - get( - waitForAll( - profiles.map((hit) => - refreshWalletProfileAtom(toBech32Hash(hit.address)) - ) - ) - ) - } - - return profiles - }, -}) - -/** - * Get profile from PFPK given a wallet address on any chain. - */ -export const pfpkProfileSelector = selectorFamily({ - key: 'pfpkProfile', - get: - (walletAddress) => - ({ get }) => - get( - pfpkProfileForBech32HashSelector( - walletAddress && toBech32Hash(walletAddress) - ) - ), -}) - -/** - * Get profile from PFPK given a wallet address's bech32 hash. - */ -export const pfpkProfileForBech32HashSelector = selectorFamily< - PfpkProfile, - string ->({ - key: 'pfpkProfileForBech32Hash', - get: - (bech32Hash) => - async ({ get }) => { - if (!bech32Hash) { - return { ...EMPTY_PFPK_PROFILE } - } - - get(refreshWalletProfileAtom(bech32Hash)) - - try { - const response = await fetch(PFPK_API_BASE + `/bech32/${bech32Hash}`) - if (response.ok) { - const profile: PfpkProfile = await response.json() - - // If profile found, add refresher dependencies for the other chains - // in the profile. This ensures that the profile will update for all - // other chains when any of the other chains update the profile. - if (profile?.chains) { - get( - waitForAll( - uniq( - Object.values(profile.chains).map(({ address }) => - toBech32Hash(address) - ) - ).map((bech32Hash) => refreshWalletProfileAtom(bech32Hash)) - ) - ) - } - - return profile - } else { - console.error(await response.json().catch(() => response.statusText)) - } - } catch (err) { - console.error(err) - } - - return { ...EMPTY_PFPK_PROFILE } - }, -}) - -// Get name for address from Stargaze Names. -export const stargazeNameSelector = selectorFamily({ - key: 'stargazeName', - get: - (walletAddress) => - async ({ get }) => { - if (!walletAddress) { - return - } - - get(refreshWalletProfileAtom(walletAddress)) - - const client = get( - cosmWasmClientForChainSelector( - MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet - ) - ) - - try { - return await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { - name: { address: walletAddress }, - }) - } catch {} - }, -}) - -// Get image for address from Stargaze Names. -export const stargazeNameImageForAddressSelector = selectorFamily< - string | undefined, - string ->({ - key: 'stargazeNameImageForAddress', - get: - (walletAddress) => - async ({ get }) => { - // Get name associated with address. - const name = get(stargazeNameSelector(walletAddress)) - if (!name) { - return - } - - const chainId = MAINNET - ? ChainId.StargazeMainnet - : ChainId.StargazeTestnet - const client = get(cosmWasmClientForChainSelector(chainId)) - - // Get NFT associated with name. - let response - try { - response = await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { - image_n_f_t: { name }, - }) - } catch { - return - } - - // If NFT exists, get image associated with NFT. - if ( - objectMatchesStructure(response, { - collection: {}, - token_id: {}, - }) - ) { - const { imageUrl } = get( - nftCardInfoSelector({ - chainId, - collection: response.collection, - tokenId: response.token_id, - }) - ) - - return imageUrl - } - }, -}) - -export const profileSelector = selectorFamily< - UnifiedProfile, - WithChainId<{ address: string }> ->({ - key: 'profile', - get: - ({ address, chainId }) => - ({ get }) => { - const profile = makeEmptyUnifiedProfile(chainId, address) - if (!address) { - return profile - } - - get(refreshWalletProfileAtom(toBech32Hash(address))) - - const pfpkProfile = get(pfpkProfileSelector(address)) - if (pfpkProfile) { - profile.uuid = pfpkProfile.uuid - profile.nonce = pfpkProfile.nonce - profile.name = pfpkProfile.name - profile.nft = pfpkProfile.nft - profile.chains = pfpkProfile.chains - } - - // Load Stargaze name as backup if no PFPK name set. - if (!profile.name) { - const stargazeNameLoadable = get( - noWait( - stargazeNameSelector( - // Use profile address for Stargaze if set, falling back to - // transforming the address (which is unreliable due to different - // chains using different HD paths). - profile.chains[ChainId.StargazeMainnet]?.address || - transformBech32Address(address, ChainId.StargazeMainnet) - ) - ) - ) - if ( - stargazeNameLoadable.state === 'hasValue' && - stargazeNameLoadable.contents - ) { - profile.name = - stargazeNameLoadable.contents + - '.' + - getChainForChainId(chainId).bech32_prefix - profile.nameSource = 'stargaze' - } - } - - // Set `imageUrl` to PFPK image, defaulting to fallback image. - profile.imageUrl = pfpkProfile?.nft?.imageUrl || profile.backupImageUrl - - // If NFT present from PFPK, get image from token once loaded. - if (pfpkProfile?.nft) { - // Don't wait for NFT info to load. When it loads, it will update. - const nftInfoLoadable = get( - noWait( - nftCardInfoSelector({ - collection: pfpkProfile.nft.collectionAddress, - tokenId: pfpkProfile.nft.tokenId, - chainId: pfpkProfile.nft.chainId, - }) - ) - ) - - // Set `imageUrl` if defined, overriding PFPK image and backup. - if ( - nftInfoLoadable.state === 'hasValue' && - nftInfoLoadable.contents?.imageUrl - ) { - profile.imageUrl = nftInfoLoadable.contents.imageUrl - } - - // Load Stargaze name image if no PFPK image. - } else if (profile.nameSource === 'stargaze') { - const stargazeNameImageLoadable = get( - noWait( - stargazeNameImageForAddressSelector( - // Use profile address for Stargaze if set, falling back to - // transforming the address (which is unreliable due to different - // chains using different HD paths). - profile.chains[ChainId.StargazeMainnet]?.address || - transformBech32Address(address, ChainId.StargazeMainnet) - ) - ) - ) - if ( - stargazeNameImageLoadable.state === 'hasValue' && - stargazeNameImageLoadable.contents - ) { - profile.imageUrl = stargazeNameImageLoadable.contents - } - } - - return profile - }, -}) diff --git a/packages/state/recoil/selectors/treasury.ts b/packages/state/recoil/selectors/treasury.ts index f00986988..4ca066988 100644 --- a/packages/state/recoil/selectors/treasury.ts +++ b/packages/state/recoil/selectors/treasury.ts @@ -179,11 +179,10 @@ export const daoTvlSelector = selectorFamily< ({ get }) => { // Native chain x/gov module. if (isConfiguredChainName(chainId, coreAddress)) { - // If chain uses a contract-based DAO, load it instead. - const govContractAddress = - getSupportedChainConfig(chainId)?.govContractAddress - - coreAddress = govContractAddress || COMMUNITY_POOL_ADDRESS_PLACEHOLDER + coreAddress = + // If chain uses a contract-based DAO, load it instead. + getSupportedChainConfig(chainId)?.govContractAddress || + COMMUNITY_POOL_ADDRESS_PLACEHOLDER } const timestamp = new Date() diff --git a/packages/state/recoil/selectors/wallet.ts b/packages/state/recoil/selectors/wallet.ts index 4086cfeef..65c4555c1 100644 --- a/packages/state/recoil/selectors/wallet.ts +++ b/packages/state/recoil/selectors/wallet.ts @@ -10,7 +10,6 @@ import { import { AccountTxSave, AccountType, - ContractVersion, ContractVersionInfo, GenericTokenBalance, LazyDaoCardProps, @@ -529,8 +528,7 @@ export const lazyWalletDaosSelector = selectorFamily< info: { chainId, coreAddress: dao, - coreVersion: - parseContractVersion(info.version) || ContractVersion.Unknown, + coreVersion: parseContractVersion(info.version), name: config.name, description: config.description, imageUrl: config.image_url || getFallbackImage(dao), diff --git a/packages/state/utils/contract.ts b/packages/state/utils/contract.ts index 4d9304649..874cd3861 100644 --- a/packages/state/utils/contract.ts +++ b/packages/state/utils/contract.ts @@ -1,7 +1,6 @@ -import { fromUtf8, toUtf8 } from '@cosmjs/encoding' +import { QueryClient } from '@tanstack/react-query' import { - ContractVersionInfo, PreProposeModule, PreProposeModuleType, PreProposeModuleTypedConfig, @@ -9,7 +8,6 @@ import { import { Config as NeutronCwdSubdaoTimelockSingleConfig } from '@dao-dao/types/contracts/NeutronCwdSubdaoTimelockSingle' import { ContractName, - INVALID_CONTRACT_ERROR_SUBSTRINGS, cosmWasmClientRouter, getRpcForChainId, parseContractVersion, @@ -22,69 +20,20 @@ import { NeutronCwdSubdaoTimelockSingleQueryClient, } from '../contracts' import { queryIndexer } from '../indexer' - -export const fetchContractInfo = async ( - chainId: string, - contractAddress: string -): Promise => { - let info: ContractVersionInfo | undefined - - // Try indexer first. - try { - info = await queryIndexer({ - type: 'contract', - address: contractAddress, - formula: 'info', - chainId, - }) - } catch (err) { - // Ignore error. - console.error(err) - } - - // If indexer fails, fallback to querying chain. - if (!info) { - try { - const client = await cosmWasmClientRouter.connect( - getRpcForChainId(chainId) - ) - const { data: contractInfo } = await client[ - 'forceGetQueryClient' - ]().wasm.queryContractRaw(contractAddress, toUtf8('contract_info')) - if (contractInfo) { - info = JSON.parse(fromUtf8(contractInfo)) - } - } catch (err) { - if ( - err instanceof Error && - INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => - (err as Error).message.includes(substring) - ) - ) { - // Ignore error. - console.error(err) - return undefined - } - - // Rethrow other errors because it should not have failed. - throw err - } - } - - return info -} +import { contractQueries } from '../query' export const fetchPreProposeModule = async ( + queryClient: QueryClient, chainId: string, preProposeAddress: string ): Promise => { - const contractInfo = await fetchContractInfo(chainId, preProposeAddress) - const contractVersion = - contractInfo && parseContractVersion(contractInfo.version) - - if (!contractInfo || !contractVersion) { - throw new Error('Failed to fetch pre propose module info') - } + const { info: contractInfo } = await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: preProposeAddress, + }) + ) + const contractVersion = parseContractVersion(contractInfo.version) let typedConfig: PreProposeModuleTypedConfig = { type: PreProposeModuleType.Other, @@ -122,8 +71,17 @@ export const fetchPreProposeModule = async ( } // Check if approver is an approver contract. - const approverContractInfo = await fetchContractInfo(chainId, approver) - if (approverContractInfo?.contract === ContractName.PreProposeApprover) { + const approverContractName = ( + await queryClient + .fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: approver, + }) + ) + .catch(() => undefined) + )?.info.contract + if (approverContractName === ContractName.PreProposeApprover) { preProposeApproverContract = approver approver = undefined diff --git a/packages/stateful/README.md b/packages/stateful/README.md index 7edc86c7a..6d8ac5fff 100644 --- a/packages/stateful/README.md +++ b/packages/stateful/README.md @@ -15,6 +15,7 @@ intelligent components that do fun stuff with data. | [`hooks`](./hooks) | Stateful React hooks that combine elements from the [`state` package](../state) and [`stateless` package](../stateless). Notably, contains hooks for interacting with on-chain smart contracts. | | [`inbox`](./inbox) | Inbox adapter system that supports various data sources for inbox items. | | [`proposal-module-adapter`](./proposal-module-adapter) | Proposal module adapter system that allows dynamic support for proposal modules in the UI. | +| [`queries`](./queries) | [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) queries. | | [`recoil`](./recoil) | [Recoil](https://recoiljs.org) atoms and selectors that require [`state`](../state) or other stateful information. | | [`server`](./server) | Isolated functions only to be run on the server. Notably, contains main [Static Site Generation](https://nextjs.org/docs/basic-features/data-fetching/get-static-props) code. | | [`utils`](./utils) | Stateful utility functions. | diff --git a/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx b/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx index c879c162a..325434052 100644 --- a/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx +++ b/packages/stateful/actions/core/authorizations/AuthzExec/index.tsx @@ -1,11 +1,12 @@ +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValueLoadable } from 'recoil' -import { isDaoSelector } from '@dao-dao/state/recoil' +import { contractQueries } from '@dao-dao/state/query' import { ChainProvider, DaoSupportedChainPickerInput, + ErrorPage, LockWithKeyEmoji, useChain, } from '@dao-dao/stateless' @@ -37,7 +38,11 @@ import { EntityDisplay, SuspenseLoader, } from '../../../../components' -import { daoInfoSelector } from '../../../../recoil' +import { + useQueryLoadingData, + useQueryLoadingDataWithError, +} from '../../../../hooks' +import { daoQueries } from '../../../../queries/dao' import { WalletActionsProvider, useActionOptions, @@ -97,30 +102,38 @@ const InnerComponentWrapper: ActionComponent< } = props const { chain_id: chainId } = useChain() - const isDaoLoadable = useRecoilValueLoadable( - isDaoSelector({ - address, + const queryClient = useQueryClient() + const isDao = useQueryLoadingData( + contractQueries.isDao(queryClient, { chainId, - }) + address, + }), + false ) - const daoInfoLoadable = useRecoilValueLoadable( - isDaoLoadable.state === 'hasValue' && isDaoLoadable.contents - ? daoInfoSelector({ - chainId, - coreAddress: address, - }) - : constSelector(undefined) + const daoInfo = useQueryLoadingDataWithError( + daoQueries.info( + useQueryClient(), + !isDao.loading && isDao.data + ? { + chainId, + coreAddress: address, + } + : undefined + ) ) - return isDaoLoadable.state === 'loading' || - daoInfoLoadable.state === 'loading' ? ( + return isDao.loading || (isDao.data && daoInfo.loading) ? ( - ) : daoInfoLoadable.state === 'hasValue' && daoInfoLoadable.contents ? ( - }> - - - - + ) : isDao.data && !daoInfo.loading ? ( + daoInfo.errored ? ( + + ) : ( + }> + + + + + ) ) : ( diff --git a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx b/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx index d9b78a0a1..fc3a30a7b 100644 --- a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx +++ b/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx @@ -1,12 +1,7 @@ +import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { constSelector } from 'recoil' -import { daoParentInfoSelector } from '@dao-dao/state/recoil' -import { - DaoEmoji, - useCachedLoadingWithError, - useChain, -} from '@dao-dao/stateless' +import { DaoEmoji, useChain } from '@dao-dao/stateless' import { ActionComponent, ActionKey, @@ -18,19 +13,24 @@ import { import { decodeJsonFromBase64, objectMatchesStructure } from '@dao-dao/utils' import { LinkWrapper } from '../../../../components' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries' import { CreateDaoComponent, CreateDaoData } from './Component' const Component: ActionComponent = (props) => { const { chain_id: chainId } = useChain() // If admin is set, attempt to load parent DAO info. - const parentDao = useCachedLoadingWithError( - props.data.admin - ? daoParentInfoSelector({ - chainId, - parentAddress: props.data.admin, - }) - : constSelector(undefined) + const parentDao = useQueryLoadingDataWithError( + daoQueries.parentInfo( + useQueryClient(), + props.data.admin + ? { + chainId, + parentAddress: props.data.admin, + } + : undefined + ) ) return ( diff --git a/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx b/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx index c915ff491..c1658cdcb 100644 --- a/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx +++ b/packages/stateful/actions/core/dao_governance/DaoAdminExec/index.tsx @@ -1,9 +1,9 @@ +import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { useFormContext } from 'react-hook-form' -import { constSelector, useRecoilValueLoadable } from 'recoil' import { DaoCoreV2Selectors, walletAdminOfDaosSelector } from '@dao-dao/state' -import { JoystickEmoji, useCachedLoadable } from '@dao-dao/stateless' +import { ErrorPage, JoystickEmoji, useCachedLoadable } from '@dao-dao/stateless' import { ActionChainContextType, ActionComponent, @@ -26,7 +26,8 @@ import { EntityDisplay, SuspenseLoader, } from '../../../../components' -import { daoInfoSelector } from '../../../../recoil' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries/dao' import { useActionOptions, useActionsForMatching, @@ -118,13 +119,16 @@ const Component: ActionComponent = (props) => { ? daoSubDaosLoadable : walletAdminOfDaosLoadable - const daoInfoLoadable = useRecoilValueLoadable( - coreAddress && isValidBech32Address(coreAddress, bech32Prefix) - ? daoInfoSelector({ - coreAddress, - chainId, - }) - : constSelector(undefined) + const daoInfo = useQueryLoadingDataWithError( + daoQueries.info( + useQueryClient(), + coreAddress && isValidBech32Address(coreAddress, bech32Prefix) + ? { + chainId, + coreAddress: address, + } + : undefined + ) ) const options: InnerOptions = { @@ -139,16 +143,18 @@ const Component: ActionComponent = (props) => { : { loading: true }, } - return daoInfoLoadable.state === 'hasValue' && !!daoInfoLoadable.contents ? ( + return daoInfo.loading ? ( + + ) : daoInfo.errored ? ( + + ) : ( } > - + - ) : ( - ) } diff --git a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx b/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx index a4b74bf1c..0a4a47ce5 100644 --- a/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx +++ b/packages/stateful/actions/core/dao_governance/VetoOrEarlyExecuteDaoProposal/index.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect } from 'react' import { useFormContext } from 'react-hook-form' @@ -29,10 +30,9 @@ import { EntityDisplay, ProposalLine, } from '../../../../components' -import { - daoInfoSelector, - daosWithVetoableProposalsSelector, -} from '../../../../recoil' +import { useQueryLoadingDataWithError } from '../../../../hooks' +import { daoQueries } from '../../../../queries/dao' +import { daosWithVetoableProposalsSelector } from '../../../../recoil' import { useActionOptions } from '../../../react' import { VetoOrEarlyExecuteDaoProposalComponent as StatelessVetoOrEarlyExecuteDaoProposalComponent, @@ -100,13 +100,17 @@ const Component: ActionComponent< setValue, ]) - const selectedDaoInfo = useCachedLoadingWithError( - chainId && coreAddress - ? daoInfoSelector({ - chainId, - coreAddress, - }) - : undefined + const queryClient = useQueryClient() + const selectedDaoInfo = useQueryLoadingDataWithError( + daoQueries.info( + queryClient, + chainId && coreAddress + ? { + chainId, + coreAddress, + } + : undefined + ) ) // Select first proposal once loaded if nothing selected. diff --git a/packages/stateful/actions/core/treasury/Spend/index.tsx b/packages/stateful/actions/core/treasury/Spend/index.tsx index b63b32d13..33f231197 100644 --- a/packages/stateful/actions/core/treasury/Spend/index.tsx +++ b/packages/stateful/actions/core/treasury/Spend/index.tsx @@ -1,4 +1,5 @@ import { coin, coins } from '@cosmjs/amino' +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector, useRecoilValue } from 'recoil' @@ -69,9 +70,10 @@ import { } from '@dao-dao/utils' import { AddressInput } from '../../../../components' +import { useQueryLoadingDataWithError } from '../../../../hooks' import { useWallet } from '../../../../hooks/useWallet' import { useProposalModuleAdapterCommonContextIfAvailable } from '../../../../proposal-module-adapter/react/context' -import { entitySelector } from '../../../../recoil' +import { entityQueries } from '../../../../queries/entity' import { useTokenBalances } from '../../../hooks/useTokenBalances' import { useActionOptions } from '../../../react' import { @@ -503,13 +505,17 @@ const Component: ActionComponent = (props) => { ]) const [currentEntity, setCurrentEntity] = useState() - const loadingEntity = useCachedLoadingWithError( - validRecipient - ? entitySelector({ - address: recipient, - chainId: toChainId, - }) - : undefined + const queryClient = useQueryClient() + const loadingEntity = useQueryLoadingDataWithError( + entityQueries.info( + queryClient, + validRecipient + ? { + address: recipient, + chainId: toChainId, + } + : undefined + ) ) // Cache last successfully loaded entity. useEffect(() => { diff --git a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx b/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx index 9fd55e404..610b66c8a 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx +++ b/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx @@ -24,8 +24,8 @@ import { } from '@dao-dao/utils' import { AddressInput, Trans } from '../../../../../components' +import { useEntity } from '../../../../../hooks' import { useWallet } from '../../../../../hooks/useWallet' -import { entitySelector } from '../../../../../recoil' import { useTokenBalances } from '../../../../hooks/useTokenBalances' import { useActionOptions } from '../../../../react' import { InstantiateTokenSwap as StatelessInstantiateTokenSwap } from '../stateless/InstantiateTokenSwap' @@ -237,27 +237,23 @@ const InnerInstantiateTokenSwap: ActionComponent< // Get counterparty entity, which reverse engineers a DAO from its polytone // proxy. - const entityLoading = useCachedLoading( + const { entity } = useEntity( counterpartyAddress && isValidBech32Address(counterpartyAddress, bech32Prefix) - ? entitySelector({ - chainId, - address: counterpartyAddress, - }) - : undefined, - undefined + ? counterpartyAddress + : '' ) // Try to retrieve governance token address, failing if not a cw20-based DAO. const counterpartyDaoGovernanceTokenAddressLoadable = useRecoilValueLoadable( - !entityLoading.loading && - entityLoading.data?.type === EntityType.Dao && + !entity.loading && + entity.data.type === EntityType.Dao && // Only care about loading the governance token if on the chain we're // creating the token swap on. - entityLoading.data.chainId === chainId + entity.data.chainId === chainId ? DaoCoreV2Selectors.tryFetchGovernanceTokenAddressSelector({ chainId, - contractAddress: entityLoading.data.address, + contractAddress: entity.data.address, }) : constSelector(undefined) ) @@ -265,12 +261,12 @@ const InnerInstantiateTokenSwap: ActionComponent< // Load balances as loadables since they refresh automatically on a timer. const counterpartyTokenBalances = useCachedLoading( counterpartyAddress && - !entityLoading.loading && - entityLoading.data && + !entity.loading && + entity.data && counterpartyDaoGovernanceTokenAddressLoadable.state !== 'loading' ? genericTokenBalancesSelector({ - chainId: entityLoading.data.chainId, - address: entityLoading.data.address, + chainId: entity.data.chainId, + address: entity.data.address, cw20GovernanceTokenAddress: counterpartyDaoGovernanceTokenAddressLoadable.state === 'hasValue' ? counterpartyDaoGovernanceTokenAddressLoadable.contents diff --git a/packages/stateful/command/contexts/generic/dao.tsx b/packages/stateful/command/contexts/generic/dao.tsx index 032b511f6..38cd368df 100644 --- a/packages/stateful/command/contexts/generic/dao.tsx +++ b/packages/stateful/command/contexts/generic/dao.tsx @@ -5,17 +5,14 @@ import { HomeOutlined, InboxOutlined, } from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRecoilState } from 'recoil' import useDeepCompareEffect from 'use-deep-compare-effect' import { navigatingToHrefAtom } from '@dao-dao/state' -import { - useCachedLoading, - useDaoInfoContext, - useDaoNavHelpers, -} from '@dao-dao/stateless' +import { useDaoInfoContext, useDaoNavHelpers } from '@dao-dao/stateless' import { ContractVersion, Feature } from '@dao-dao/types' import { CommandModalContextMaker, @@ -26,8 +23,13 @@ import { import { getDisplayNameForChainId, getFallbackImage } from '@dao-dao/utils' import { DaoProvidersWithoutInfo } from '../../../components' -import { useDaoTabs, useFollowingDaos, useGovDaoTabs } from '../../../hooks' -import { chainSubDaoInfosSelector, subDaoInfosSelector } from '../../../recoil' +import { + useDaoTabs, + useFollowingDaos, + useGovDaoTabs, + useQueryLoadingData, +} from '../../../hooks' +import { daoQueries } from '../../../queries' export const makeGenericDaoContext: CommandModalContextMaker<{ dao: CommandModalDaoInfo @@ -68,19 +70,19 @@ export const makeGenericDaoContext: CommandModalContextMaker<{ const daoPageHref = getDaoPath(coreAddress) const createProposalHref = getDaoProposalPath(coreAddress, 'create') - const subDaosLoading = useCachedLoading( + const queryClient = useQueryClient() + const subDaosLoading = useQueryLoadingData( coreVersion === ContractVersion.Gov - ? chainSubDaoInfosSelector({ - chainId, - }) - : supportedFeatures[Feature.SubDaos] - ? subDaoInfosSelector({ + ? daoQueries.chainSubDaoInfos(queryClient, { chainId, - coreAddress, }) - : // Passing undefined here returns an infinite loading state, which is - // fine because it's never used. - undefined, + : { + ...daoQueries.subDaoInfos(queryClient, { + chainId, + coreAddress, + }), + enabled: !!supportedFeatures[Feature.SubDaos], + }, [] ) diff --git a/packages/stateful/components/AddressInput.tsx b/packages/stateful/components/AddressInput.tsx index d36d476a2..2eca9a123 100644 --- a/packages/stateful/components/AddressInput.tsx +++ b/packages/stateful/components/AddressInput.tsx @@ -1,3 +1,4 @@ +import { useQueries, useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { useMemo } from 'react' import { FieldValues, Path, useFormContext } from 'react-hook-form' @@ -5,14 +6,11 @@ import { useTranslation } from 'react-i18next' import { waitForNone } from 'recoil' import { useDeepCompareMemoize } from 'use-deep-compare-effect' -import { - searchDaosSelector, - searchProfilesByNamePrefixSelector, -} from '@dao-dao/state/recoil' +import { profileQueries } from '@dao-dao/state/query' +import { searchDaosSelector } from '@dao-dao/state/recoil' import { AddressInput as StatelessAddressInput, useCachedLoadable, - useCachedLoading, useChain, } from '@dao-dao/stateless' import { AddressInputProps, Entity, EntityType } from '@dao-dao/types' @@ -20,9 +18,11 @@ import { POLYTONE_CONFIG_PER_CHAIN, getAccountAddress, isValidBech32Address, + makeCombineQueryResultsIntoLoadingData, } from '@dao-dao/utils' -import { entitySelector } from '../recoil' +import { useQueryLoadingDataWithError } from '../hooks' +import { entityQueries } from '../queries/entity' import { EntityDisplay } from './EntityDisplay' export const AddressInput = < @@ -45,14 +45,17 @@ export const AddressInput = < // Don't search name if it's an address. !isValidBech32Address(formValue, currentChain.bech32_prefix) - const searchProfilesLoadable = useCachedLoadable( - hasFormValue && props.type !== 'contract' - ? searchProfilesByNamePrefixSelector({ - chainId: currentChain.chain_id, - namePrefix: formValue, - }) - : undefined + const searchProfilesLoading = useQueryLoadingDataWithError( + profileQueries.searchByNamePrefix( + hasFormValue && props.type !== 'contract' + ? { + chainId: currentChain.chain_id, + namePrefix: formValue, + } + : undefined + ) ) + // Search DAOs on current chains and all polytone-connected chains so we can // find polytone accounts. const searchDaosLoadable = useCachedLoadable( @@ -76,13 +79,14 @@ export const AddressInput = < : undefined ) - const loadingEntities = useCachedLoading( - waitForNone([ - ...(searchProfilesLoadable.state === 'hasValue' - ? searchProfilesLoadable.contents.map(({ address }) => - entitySelector({ - address, + const queryClient = useQueryClient() + const loadingEntities = useQueries({ + queries: [ + ...(!searchProfilesLoading.loading && !searchProfilesLoading.errored + ? searchProfilesLoading.data.map(({ address }) => + entityQueries.info(queryClient, { chainId: currentChain.chain_id, + address, }) ) : []), @@ -90,7 +94,7 @@ export const AddressInput = < ? searchDaosLoadable.contents.flatMap((loadable) => loadable.state === 'hasValue' ? loadable.contents.map(({ chainId, id: address }) => - entitySelector({ + entityQueries.info(queryClient, { chainId, address, }) @@ -98,34 +102,38 @@ export const AddressInput = < : [] ) : []), - ]), - [] - ) - - const entities = loadingEntities.loading - ? [] - : // Only show entities that are on the current chain or are DAOs with - // accounts (polytone probably) on the current chain. - loadingEntities.data - .filter( - (entity) => - entity.state === 'hasValue' && - (entity.contents.chainId === currentChain.chain_id || - (entity.contents.type === EntityType.Dao && - getAccountAddress({ - accounts: entity.contents.daoInfo.accounts, - chainId: currentChain.chain_id, - }))) - ) - .map((entity) => entity.contents as Entity) + ], + combine: useMemo( + () => + makeCombineQueryResultsIntoLoadingData({ + firstLoad: 'none', + transform: (entities) => + // Only show entities that are on the current chain or are DAOs with + // accounts (polytone probably) on the current chain. + entities.filter( + (entity) => + entity.chainId === currentChain.chain_id || + (entity.type === EntityType.Dao && + getAccountAddress({ + accounts: entity.daoInfo.accounts, + chainId: currentChain.chain_id, + })) + ), + }), + [currentChain.chain_id] + ), + }) // Use Fuse to search combined profiles and DAOs by name so that is most // relevant (as opposed to just sticking DAOs after profiles). const fuse = useMemo( - () => new Fuse(entities, { keys: ['name'] }), + () => + new Fuse(loadingEntities.loading ? [] : loadingEntities.data, { + keys: ['name'], + }), // Only reinstantiate fuse when entities deeply changes. // eslint-disable-next-line react-hooks/exhaustive-deps - useDeepCompareMemoize([entities]) + useDeepCompareMemoize([loadingEntities]) ) const searchedEntities = useMemo( () => (hasFormValue ? fuse.search(formValue).map(({ item }) => item) : []), @@ -143,9 +151,9 @@ export const AddressInput = < entities: searchedEntities, loading: (props.type !== 'contract' && - (searchProfilesLoadable.state === 'loading' || - (searchProfilesLoadable.state === 'hasValue' && - searchProfilesLoadable.updating))) || + (searchProfilesLoading.loading || + (!searchProfilesLoading.errored && + searchProfilesLoading.updating))) || (props.type !== 'wallet' && (searchDaosLoadable.state === 'loading' || (searchDaosLoadable.state === 'hasValue' && @@ -154,10 +162,7 @@ export const AddressInput = < (loadable) => loadable.state === 'loading' ))))) || loadingEntities.loading || - !!loadingEntities.updating || - loadingEntities.data.some( - (loadable) => loadable.state === 'loading' - ), + !!loadingEntities.updating, } : undefined } diff --git a/packages/stateful/components/StateProvider.tsx b/packages/stateful/components/StateProvider.tsx new file mode 100644 index 000000000..3f5aeb5e0 --- /dev/null +++ b/packages/stateful/components/StateProvider.tsx @@ -0,0 +1,59 @@ +import { + DehydratedState, + QueryClientProvider, + useQueryClient, +} from '@tanstack/react-query' +import { ReactNode, useEffect, useMemo } from 'react' +import { RecoilRoot, useSetRecoilState } from 'recoil' + +import { makeReactQueryClient, queryClientAtom } from '@dao-dao/state' + +export type StateProviderProps = { + /** + * Children to render. + */ + children: ReactNode + /** + * Optional dehyrated state from a react-query client instance on the server + * to initialize data. + */ + dehyratedState?: DehydratedState +} + +/** + * A provider that wraps an app with the state providers, like React Query and + * Recoil. + */ +export const StateProvider = ({ + children, + dehyratedState, +}: StateProviderProps) => { + const client = useMemo( + () => makeReactQueryClient(dehyratedState), + [dehyratedState] + ) + + return ( + + set(queryClientAtom, client) + } + > + {children} + + + ) +} + +const InnerStateProvider = ({ children }: { children: ReactNode }) => { + const queryClient = useQueryClient() + const setQueryClient = useSetRecoilState(queryClientAtom) + // Update Recoil atom when the query client changes. + useEffect(() => { + setQueryClient(queryClient) + }, [queryClient, setQueryClient]) + + return <>{children} +} diff --git a/packages/stateful/components/dao/CreateDaoForm.tsx b/packages/stateful/components/dao/CreateDaoForm.tsx index 2396a3e0f..513d2d4d1 100644 --- a/packages/stateful/components/dao/CreateDaoForm.tsx +++ b/packages/stateful/components/dao/CreateDaoForm.tsx @@ -1,4 +1,5 @@ import { ArrowBack } from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' import cloneDeep from 'lodash.clonedeep' import merge from 'lodash.merge' import { useEffect, useMemo, useState } from 'react' @@ -12,8 +13,8 @@ import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { constSelector, useRecoilState, useRecoilValue } from 'recoil' +import { contractQueries } from '@dao-dao/state/query' import { averageColorSelector, walletChainIdAtom } from '@dao-dao/state/recoil' -import { fetchContractInfo } from '@dao-dao/state/utils' import { Button, ChainProvider, @@ -32,7 +33,6 @@ import { } from '@dao-dao/stateless' import { ActionKey, - ContractVersion, CreateDaoContext, CreateDaoCustomValidator, DaoPageMode, @@ -148,6 +148,7 @@ export const InnerCreateDaoForm = ({ }: CreateDaoFormProps) => { const { t } = useTranslation() const daoInfo = useDaoInfoContextIfAvailable() + const queryClient = useQueryClient() const chainContext = useSupportedChainContext() const { @@ -523,10 +524,13 @@ export const InnerCreateDaoForm = ({ error: (err) => processError(err), }) - const info = await fetchContractInfo(chainId, coreAddress) - const coreVersion = - (info && parseContractVersion(info.version)) || - ContractVersion.Unknown + const { info } = await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: coreAddress, + }) + ) + const coreVersion = parseContractVersion(info.version) // Don't set following on SDA. Only dApp. if (mode !== DaoPageMode.Sda) { diff --git a/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx b/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx index ddcc4b5f0..abc8e82c2 100644 --- a/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx +++ b/packages/stateful/components/dao/DaoApproverProposalContentDisplay.tsx @@ -1,3 +1,4 @@ +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useRecoilValue } from 'recoil' @@ -23,7 +24,7 @@ import { useProposalModuleAdapter, useProposalModuleAdapterContext, } from '../../proposal-module-adapter' -import { daoInfoSelector } from '../../recoil' +import { daoQueries } from '../../queries/dao' import { EntityDisplay } from '../EntityDisplay' import { IconButtonLink } from '../IconButtonLink' import { SuspenseLoader } from '../SuspenseLoader' @@ -69,8 +70,8 @@ export const DaoApproverProposalContentDisplay = ({ const { approvalDao, preProposeApprovalContract } = prePropose.config const { chain_id: chainId } = useChain() - const daoInfo = useRecoilValue( - daoInfoSelector({ + const { data: daoInfo } = useSuspenseQuery( + daoQueries.info(useQueryClient(), { chainId, coreAddress: approvalDao, }) diff --git a/packages/stateful/components/dao/DaoCard.tsx b/packages/stateful/components/dao/DaoCard.tsx index f8aa7cf6c..b4d51460d 100644 --- a/packages/stateful/components/dao/DaoCard.tsx +++ b/packages/stateful/components/dao/DaoCard.tsx @@ -1,3 +1,6 @@ +import { useRecoilValue } from 'recoil' + +import { mountedInBrowserAtom } from '@dao-dao/state/recoil' import { DaoCard as StatelessDaoCard, useCachedLoading, @@ -18,6 +21,8 @@ export const DaoCard = (props: StatefulDaoCardProps) => { // chains from the currently connected profile and find the correct one. const { chains } = useProfile() + const mountedInBrowser = useRecoilValue(mountedInBrowserAtom) + const { isFollowing, setFollowing, setUnfollowing, updatingFollowing } = useFollowingDaos() @@ -55,6 +60,16 @@ export const DaoCard = (props: StatefulDaoCardProps) => { LinkWrapper={LinkWrapper} follow={follow} lazyData={lazyData} + showParentDao={ + /* + * Hide the parent DAO until the app is mounted in the browser since + * rendering it on the server causes a hydration error for some horrible + * reason. I think it has something to do with the fact that you're not + * supposed to nest an A tag inside of another A tag, and maybe the + * Next.js server is sanitizing it or something. Anyways, rip. + */ + mountedInBrowser + } /> ) } diff --git a/packages/stateful/components/dao/DaoPageWrapper.tsx b/packages/stateful/components/dao/DaoPageWrapper.tsx index 54cc144a8..e2644910d 100644 --- a/packages/stateful/components/dao/DaoPageWrapper.tsx +++ b/packages/stateful/components/dao/DaoPageWrapper.tsx @@ -1,25 +1,21 @@ +import { DehydratedState } from '@tanstack/react-query' import { NextSeo } from 'next-seo' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useMemo } from 'react' +import { PropsWithChildren, useEffect } from 'react' import { useRecoilState } from 'recoil' -import { accountsSelector, walletChainIdAtom } from '@dao-dao/state/recoil' +import { walletChainIdAtom } from '@dao-dao/state/recoil' import { DaoNotFound, ErrorPage500, PageLoader, useAppContext, - useCachedLoadingWithError, useThemeContext, } from '@dao-dao/stateless' import { CommonProposalInfo, DaoInfo } from '@dao-dao/types' -import { - getFallbackImage, - transformIpfsUrlToHttpsIfNecessary, -} from '@dao-dao/utils' +import { transformIpfsUrlToHttpsIfNecessary } from '@dao-dao/utils' import { makeDaoContext, makeGenericContext } from '../../command' -import { daoInfoSelector } from '../../recoil' import { PageHeaderContent } from '../PageHeaderContent' import { SuspenseLoader } from '../SuspenseLoader' import { DaoProviders } from './DaoProviders' @@ -32,6 +28,7 @@ export type DaoPageWrapperProps = PropsWithChildren<{ info?: DaoInfo error?: string setIcon?: (icon: string | undefined) => void + reactQueryDehydratedState?: DehydratedState }> export type DaoProposalProps = DaoPageWrapperProps & { @@ -43,7 +40,7 @@ export const DaoPageWrapper = ({ title, description, accentColor, - info: _info, + info, error, setIcon, children, @@ -55,7 +52,7 @@ export const DaoPageWrapper = ({ const [walletChainId, setWalletChainId] = useRecoilState(walletChainIdAtom) // Update walletChainId to whatever the current DAO is to ensure we connect // correctly. - const currentChainId = _info?.chainId + const currentChainId = info?.chainId useEffect(() => { if (currentChainId && currentChainId !== walletChainId) { setWalletChainId(currentChainId) @@ -87,68 +84,29 @@ export const DaoPageWrapper = ({ setAccentColor(accentColor ?? undefined) }, [accentColor, setAccentColor, isReady, isFallback, theme]) - // Load all accounts since the static props only loads some. This should load - // faster than the DAO info selector below that will eventually replace this. - const accounts = useCachedLoadingWithError( - _info - ? accountsSelector({ - chainId: _info.chainId, - address: _info.coreAddress, - }) - : undefined - ) - - const info = useMemo( - (): DaoInfo | undefined => - _info && { - ..._info, - accounts: - accounts.loading || accounts.errored ? _info.accounts : accounts.data, - }, - [_info, accounts] - ) - - // Load DAO info once static props are loaded so it's more up to date. - const loadingDaoInfo = useCachedLoadingWithError( - info - ? daoInfoSelector({ - chainId: info.chainId, - coreAddress: info.coreAddress, - }) - : undefined - ) - - // Use the loading info once it's loaded, otherwise fallback to info from - // static props. - const loadedInfo = - !loadingDaoInfo.loading && !loadingDaoInfo.errored - ? loadingDaoInfo.data - : info - // Set icon for the page from info if setIcon is present. useEffect(() => { if (setIcon) { setIcon( - loadedInfo?.imageUrl - ? transformIpfsUrlToHttpsIfNecessary(loadedInfo.imageUrl) + info?.imageUrl + ? transformIpfsUrlToHttpsIfNecessary(info.imageUrl) : undefined ) } - }, [setIcon, loadedInfo?.imageUrl]) + }, [setIcon, info?.imageUrl]) // On load, set DAO context for command modal. useEffect(() => { - if (setRootCommandContextMaker && loadedInfo) { + if (setRootCommandContextMaker && info) { setRootCommandContextMaker((options) => makeDaoContext({ ...options, dao: { - chainId: loadedInfo.chainId, - coreAddress: loadedInfo.coreAddress, - coreVersion: loadedInfo.coreVersion, - name: loadedInfo.name, - imageUrl: - loadedInfo.imageUrl || getFallbackImage(loadedInfo.coreAddress), + chainId: info.chainId, + coreAddress: info.coreAddress, + coreVersion: info.coreVersion, + name: info.name, + imageUrl: info.imageUrl, }, }) ) @@ -160,7 +118,7 @@ export const DaoPageWrapper = ({ setRootCommandContextMaker(makeGenericContext) } } - }, [loadedInfo, setRootCommandContextMaker]) + }, [info, setRootCommandContextMaker]) return ( <> @@ -182,8 +140,8 @@ export const DaoPageWrapper = ({ {/* On fallback page (waiting for static props), `info` is not yet present. Let's just display a loader until `info` is loaded. We can't access translations until static props are loaded anyways. */} }> - {loadedInfo ? ( - + {info ? ( + {/* Suspend children to prevent unmounting and remounting the context providers inside it every time something needs to suspend (which causes a lot of flickering loading states). */} }> {children} diff --git a/packages/stateful/components/dao/DaoProviders.tsx b/packages/stateful/components/dao/DaoProviders.tsx index 2be06b1a2..bccb9b1e6 100644 --- a/packages/stateful/components/dao/DaoProviders.tsx +++ b/packages/stateful/components/dao/DaoProviders.tsx @@ -1,3 +1,4 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' import { ReactNode } from 'react' import { @@ -5,12 +6,11 @@ import { DaoInfoContext, ErrorPage, Loader, - useCachedLoadingWithError, } from '@dao-dao/stateless' import { ContractVersion, DaoInfo } from '@dao-dao/types' import { DaoActionsProvider } from '../../actions' -import { daoInfoSelector } from '../../recoil' +import { daoQueries } from '../../queries/dao' import { VotingModuleAdapterProvider } from '../../voting-module-adapter' import { SuspenseLoader } from '../SuspenseLoader' @@ -63,20 +63,23 @@ export const DaoProvidersWithoutInfo = ({ coreAddress, children, }: DaoProvidersWithoutInfoProps) => { - const infoLoading = useCachedLoadingWithError( - daoInfoSelector({ + const daoInfoQuery = useQuery( + daoQueries.info(useQueryClient(), { chainId, coreAddress, }) ) return ( - } forceFallback={infoLoading.loading}> - {!infoLoading.loading && - (infoLoading.errored ? ( - + } + forceFallback={daoInfoQuery.isPending} + > + {!daoInfoQuery.isPending && + (daoInfoQuery.isError ? ( + ) : ( - {children} + {children} ))} ) diff --git a/packages/stateful/components/dao/LazyDaoCard.tsx b/packages/stateful/components/dao/LazyDaoCard.tsx index 39c05d8dc..2ce887ad2 100644 --- a/packages/stateful/components/dao/LazyDaoCard.tsx +++ b/packages/stateful/components/dao/LazyDaoCard.tsx @@ -1,24 +1,24 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' import { useTranslation } from 'react-i18next' -import { useCachedLoadingWithError } from '@dao-dao/stateless' import { LazyDaoCardProps } from '@dao-dao/types' import { processError } from '@dao-dao/utils' -import { daoInfoSelector } from '../../recoil' +import { daoQueries } from '../../queries/dao' import { DaoCard } from './DaoCard' export const LazyDaoCard = (props: LazyDaoCardProps) => { const { t } = useTranslation() - const daoInfo = useCachedLoadingWithError( - daoInfoSelector({ + const daoInfoQuery = useQuery( + daoQueries.info(useQueryClient(), { chainId: props.info.chainId, coreAddress: props.info.coreAddress, }) ) - return daoInfo.loading ? ( + return daoInfoQuery.isPending ? ( { admin: '', }} /> - ) : daoInfo.errored || !daoInfo.data ? ( + ) : daoInfoQuery.isError ? ( { description: t('error.unexpectedError') + '\n' + - processError( - daoInfo.errored ? daoInfo.error : t('error.loadingData'), - { - forceCapture: false, - } - ), + processError(daoInfoQuery.error, { + forceCapture: false, + }), // Unused. supportedFeatures: {} as any, votingModuleAddress: '', @@ -69,6 +66,6 @@ export const LazyDaoCard = (props: LazyDaoCardProps) => { }} /> ) : ( - + ) } diff --git a/packages/stateful/components/dao/tabs/SubDaosTab.tsx b/packages/stateful/components/dao/tabs/SubDaosTab.tsx index 3bc53c36a..102509dee 100644 --- a/packages/stateful/components/dao/tabs/SubDaosTab.tsx +++ b/packages/stateful/components/dao/tabs/SubDaosTab.tsx @@ -1,7 +1,7 @@ +import { useQueryClient } from '@tanstack/react-query' + import { SubDaosTab as StatelessSubDaosTab, - useCachedLoading, - useChain, useDaoInfoContext, useDaoNavHelpers, } from '@dao-dao/stateless' @@ -9,24 +9,26 @@ import { ActionKey, Feature } from '@dao-dao/types' import { getDaoProposalSinglePrefill } from '@dao-dao/utils' import { useActionForKey } from '../../../actions' -import { useMembership } from '../../../hooks' -import { subDaoInfosSelector } from '../../../recoil' +import { useMembership, useQueryLoadingData } from '../../../hooks' +import { daoQueries } from '../../../queries' import { ButtonLink } from '../../ButtonLink' import { DaoCard } from '../DaoCard' export const SubDaosTab = () => { - const { chain_id: chainId } = useChain() const daoInfo = useDaoInfoContext() const { getDaoPath, getDaoProposalPath } = useDaoNavHelpers() const { isMember = false } = useMembership(daoInfo) - const subDaos = useCachedLoading( - daoInfo.supportedFeatures[Feature.SubDaos] - ? subDaoInfosSelector({ chainId, coreAddress: daoInfo.coreAddress }) - : // Passing undefined here returns an infinite loading state, which is - // fine because it's never used. - undefined, + const queryClient = useQueryClient() + const subDaos = useQueryLoadingData( + { + ...daoQueries.subDaoInfos(queryClient, { + chainId: daoInfo.chainId, + coreAddress: daoInfo.coreAddress, + }), + enabled: !!daoInfo.supportedFeatures[Feature.SubDaos], + }, [] ) diff --git a/packages/stateful/components/gov/GovSubDaosTab.tsx b/packages/stateful/components/gov/GovSubDaosTab.tsx index ed142abb8..1b40f1bdc 100644 --- a/packages/stateful/components/gov/GovSubDaosTab.tsx +++ b/packages/stateful/components/gov/GovSubDaosTab.tsx @@ -1,30 +1,27 @@ -import { waitForAll } from 'recoil' - import { DaoCardLoader, GridCardContainer, SubDaosTab as StatelessSubDaosTab, - useCachedLoading, useChain, } from '@dao-dao/stateless' import { getSupportedChainConfig } from '@dao-dao/utils' import { GovActionsProvider } from '../../actions' -import { daoInfoSelector } from '../../recoil' +import { useLoadingDaos } from '../../hooks' import { ButtonLink } from '../ButtonLink' import { DaoCard } from '../dao/DaoCard' export const GovSubDaosTab = () => { const { chain_id: chainId } = useChain() - const subDaos = useCachedLoading( - waitForAll( - getSupportedChainConfig(chainId)?.subDaos?.map((coreAddress) => - daoInfoSelector({ chainId, coreAddress }) - ) ?? [] - ), - [] - ) + const subDaos = useLoadingDaos({ + loading: false, + data: + getSupportedChainConfig(chainId)?.subDaos?.map((coreAddress) => ({ + chainId, + coreAddress, + })) ?? [], + }) return ( { }) ) - const profile = useCachedLoading( - profileSelector({ + const profile = useQueryLoadingData( + profileQueries.unified(useQueryClient(), { chainId: configuredChain.chainId, address: accountAddress, }), diff --git a/packages/stateful/components/pages/Home.tsx b/packages/stateful/components/pages/Home.tsx index c10eddbb9..cfac0cf3f 100644 --- a/packages/stateful/components/pages/Home.tsx +++ b/packages/stateful/components/pages/Home.tsx @@ -1,3 +1,4 @@ +import { DehydratedState } from '@tanstack/react-query' import clsx from 'clsx' import { NextPage } from 'next' import { NextSeo } from 'next-seo' @@ -49,6 +50,11 @@ export type StatefulHomeProps = { * Optionally show chain x/gov DAOs. */ chainGovDaos?: DaoInfo[] + /** + * Dehydrated react query state used by the server to preload data. This is + * accessed in the _app.tsx file. + */ + reactQueryDehydratedState?: DehydratedState } export const Home: NextPage = ({ @@ -113,6 +119,35 @@ export const Home: NextPage = ({ : undefined const featuredDaosLoading = useLoadingFeaturedDaoCards(chainId) + const featuredDaos: LoadingData = + !chainId || !chainGovDaos + ? // If not on a chain-specific page, show all featured DAOs. + featuredDaosLoading + : featuredDaosLoading.loading || chainGovDaos.loading + ? { + loading: true, + } + : { + loading: false, + updating: featuredDaosLoading.updating, + // On a chain-specific page, remove featured DAOs that show + // up in the chain governance section. + data: featuredDaosLoading.data.filter( + (featured) => + !chainGovDaos.data.some( + (chain) => + featured.info.coreAddress === chain.info.coreAddress || + // If the chain itself uses a real DAO for its + // governance, such as Neutron, hide it from + // featured as well since it shows up above. This is + // needed because the DAO in the featured list uses + // the DAO's real address, while the DAO in the + // chain x/gov list is the name of the chain. + featured.info.coreAddress === + selectedChain?.govContractAddress + ) + ), + } return ( <> @@ -140,7 +175,7 @@ export const Home: NextPage = ({ ) : ( -
+ <> @@ -188,44 +223,16 @@ export const Home: NextPage = ({ } /> - - !chainGovDaos.data.some( - (chain) => - featured.info.coreAddress === - chain.info.coreAddress || - // If the chain itself uses a real DAO for its - // governance, such as Neutron, hide it from - // featured as well since it shows up above. This is - // needed because the DAO in the featured list uses - // the DAO's real address, while the DAO in the - // chain x/gov list is the name of the chain. - featured.info.coreAddress === - selectedChain?.govContractAddress - ) - ), - } - } - openSearch={openSearch} - stats={stats} - /> -
+
+ +
+ )} ) diff --git a/packages/stateful/components/wallet/WalletProvider.tsx b/packages/stateful/components/wallet/WalletProvider.tsx index 0831f3e21..cb6d8267c 100644 --- a/packages/stateful/components/wallet/WalletProvider.tsx +++ b/packages/stateful/components/wallet/WalletProvider.tsx @@ -23,7 +23,6 @@ import { wallets as trustWallets } from '@cosmos-kit/trust' import { wallets as vectisWallets } from '@cosmos-kit/vectis' import { PromptSign, makeWeb3AuthWallets } from '@cosmos-kit/web3auth' import { wallets as xdefiWallets } from '@cosmos-kit/xdefi' -import { assets, chains } from 'chain-registry' import { PropsWithChildren, ReactNode, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { usePrevious } from 'react-use' @@ -41,6 +40,8 @@ import { SITE_TITLE, SITE_URL, WEB3AUTH_CLIENT_ID, + assets, + chains, getChainForChainId, getKeplrFromWindow, getSignerOptions, @@ -176,10 +177,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => { return ( { return currentChainId }, [address, currentBech32Prefix, currentChainId]) - const entity = useCachedLoading( - address - ? entitySelector({ - chainId, - address, - }) - : undefined, - // Should never error as it uses loadables internally. + const entity = useQueryLoadingData( + entityQueries.info( + useQueryClient(), + address + ? { + chainId, + address, + } + : undefined + ), + // Should never error but just in case... { type: EntityType.Wallet, chainId, diff --git a/packages/stateful/hooks/useLoadingDaos.ts b/packages/stateful/hooks/useLoadingDaos.ts index 2460bde3a..cf8cb4c2f 100644 --- a/packages/stateful/hooks/useLoadingDaos.ts +++ b/packages/stateful/hooks/useLoadingDaos.ts @@ -1,82 +1,66 @@ +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' import { constSelector, useRecoilValueLoadable, waitForAll } from 'recoil' -import { - followingDaosSelector, - indexerFeaturedDaosSelector, -} from '@dao-dao/state/recoil' -import { useCachedLoadable } from '@dao-dao/stateless' +import { daoQueries } from '@dao-dao/state/query' +import { followingDaosSelector } from '@dao-dao/state/recoil' import { DaoInfo, DaoSource, LoadingData, StatefulDaoCardProps, } from '@dao-dao/types' -import { getSupportedChains } from '@dao-dao/utils' +import { makeCombineQueryResultsIntoLoadingData } from '@dao-dao/utils' -import { daoInfoSelector } from '../recoil' +import { daoQueries as statefulDaoQueries } from '../queries/dao' import { useProfile } from './useProfile' +import { useQueryLoadingData } from './useQueryLoadingData' export const useLoadingDaos = ( daos: LoadingData, alphabetize = false ): LoadingData => { - const daoInfosLoadable = useCachedLoadable( - !daos.loading - ? waitForAll( - daos.data.map(({ chainId, coreAddress }) => - daoInfoSelector({ - chainId, - coreAddress, - }) - ) - ) - : undefined - ) - - return daoInfosLoadable.state !== 'hasValue' - ? { loading: true } - : { - loading: false, - updating: daoInfosLoadable.updating, - data: (daoInfosLoadable.contents.filter(Boolean) as DaoInfo[]).sort( - (a, b) => (alphabetize ? a.name.localeCompare(b.name) : 0) + const queryClient = useQueryClient() + return useQueries({ + queries: daos.loading + ? [] + : daos.data.map(({ chainId, coreAddress }) => + statefulDaoQueries.info(queryClient, { + chainId, + coreAddress, + }) ), - } + combine: useMemo( + () => + makeCombineQueryResultsIntoLoadingData({ + transform: (infos) => + infos.sort((a, b) => + alphabetize ? a.name.localeCompare(b.name) : 0 + ), + }), + [alphabetize] + ), + }) } export const useLoadingFeaturedDaoCards = ( - // If passed, will only load DAOs from this chain. Otherwise, will load - // from all chains. + /** + * If passed, only load DAOs from this chain. Otherwise, load from all chains. + */ chainId?: string ): LoadingData => { - const chains = getSupportedChains().filter( - ({ chain: { chain_id } }) => !chainId || chain_id === chainId - ) - const featuredDaos = useRecoilValueLoadable( - waitForAll( - chains.map(({ chain }) => indexerFeaturedDaosSelector(chain.chain_id)) - ) - ) + const featuredDaos = useQueryLoadingData(daoQueries.listFeatured(), []) const daos = useLoadingDaos( - featuredDaos.state === 'loading' + featuredDaos.loading ? { loading: true } - : featuredDaos.state === 'hasError' + : !featuredDaos.data ? { loading: false, data: [] } : { loading: false, - data: chains - .flatMap( - ({ chain }, index) => - featuredDaos.contents[index]?.map( - ({ address: coreAddress, order }) => ({ - chainId: chain.chain_id, - coreAddress, - order, - }) - ) || [] - ) - .sort((a, b) => a.order - b.order), + data: featuredDaos.data.filter( + (featured) => !chainId || featured.chainId === chainId + ), } ) diff --git a/packages/stateful/hooks/useManageProfile.ts b/packages/stateful/hooks/useManageProfile.ts index d206c4f3b..6ab460ae1 100644 --- a/packages/stateful/hooks/useManageProfile.ts +++ b/packages/stateful/hooks/useManageProfile.ts @@ -1,10 +1,9 @@ import { toHex } from '@cosmjs/encoding' +import { useQueries, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { waitForNone } from 'recoil' -import { profileSelector } from '@dao-dao/state' -import { useCachedLoading, useCachedLoadingWithError } from '@dao-dao/stateless' +import { profileQueries } from '@dao-dao/state' import { AddChainsFunction, AddChainsStatus, @@ -18,12 +17,14 @@ import { PFPK_API_BASE, SignedBody, getDisplayNameForChainId, + makeCombineQueryResultsIntoLoadingData, makeEmptyUnifiedProfile, makeManuallyResolvedPromise, signOffChainAuth, } from '@dao-dao/utils' import { useCfWorkerAuthPostRequest } from './useCfWorkerAuthPostRequest' +import { useQueryLoadingData } from './useQueryLoadingData' import { useRefreshProfile } from './useRefreshProfile' import { useWallet } from './useWallet' @@ -145,8 +146,8 @@ export const useManageProfile = ({ loadAccount: true, }) - const profile = useCachedLoading( - profileSelector({ + const profile = useQueryLoadingData( + profileQueries.unified(useQueryClient(), { chainId: walletChainId, address, }), @@ -387,16 +388,22 @@ export const useManageProfile = ({ ).filter( (chainWallet) => !!chainWallet.isWalletConnected && !!chainWallet.address ) - const otherChainWalletProfiles = useCachedLoadingWithError( - waitForNone( - otherConnectedChainWallets.map((chainWallet) => - profileSelector({ - chainId: chainWallet.chainId, - address: chainWallet.address!, - }) - ) - ) - ) + const queryClient = useQueryClient() + const otherChainWalletProfiles = useQueries({ + queries: otherConnectedChainWallets.map((chainWallet) => + profileQueries.unified(queryClient, { + chainId: chainWallet.chainId, + address: chainWallet.address!, + }) + ), + combine: useMemo( + () => + makeCombineQueryResultsIntoLoadingData({ + firstLoad: 'none', + }), + [] + ), + }) const merge: UseManageProfileReturn['merge'] = useMemo(() => { // Get all profiles attached to this wallet that are different from the @@ -404,21 +411,17 @@ export const useManageProfile = ({ const profilesToMerge = currentChainWallet && !profile.loading && - !otherChainWalletProfiles.loading && - !otherChainWalletProfiles.errored - ? otherChainWalletProfiles.data.flatMap((loadable) => { - const chainProfile = loadable.valueMaybe() + !otherChainWalletProfiles.loading + ? otherChainWalletProfiles.data.flatMap((chainProfile) => { if ( - // If not yet loaded, ignore. - !chainProfile || // If profile exists, UUID matches current chain wallet profile // and this chain wallet has been added to the profile, ignore. If // profile does not exist or chain has not been explicitly added, // we want to merge it into the current profile. - (chainProfile.uuid && - chainProfile.uuid === profile.data.uuid && - profile.data.chains[chainProfile.source.chainId]?.address === - chainProfile.source.address) + chainProfile.uuid && + chainProfile.uuid === profile.data.uuid && + profile.data.chains[chainProfile.source.chainId]?.address === + chainProfile.source.address ) { return [] } diff --git a/packages/stateful/hooks/useProfile.ts b/packages/stateful/hooks/useProfile.ts index 33b0c47a7..d5e0255ff 100644 --- a/packages/stateful/hooks/useProfile.ts +++ b/packages/stateful/hooks/useProfile.ts @@ -1,5 +1,6 @@ -import { profileSelector } from '@dao-dao/state' -import { useCachedLoading } from '@dao-dao/stateless' +import { useQueryClient } from '@tanstack/react-query' + +import { profileQueries } from '@dao-dao/state' import { LoadingData, ProfileChain, UnifiedProfile } from '@dao-dao/types' import { MAINNET, @@ -10,6 +11,7 @@ import { toBech32Hash, } from '@dao-dao/utils' +import { useQueryLoadingData } from './useQueryLoadingData' import { useRefreshProfile } from './useRefreshProfile' import { useWallet } from './useWallet' @@ -103,8 +105,8 @@ export const useProfile = ({ const profileAddress = address || currentAddress - const profile = useCachedLoading( - profileSelector({ + const profile = useQueryLoadingData( + profileQueries.unified(useQueryClient(), { chainId: walletChainId, address: profileAddress, }), diff --git a/packages/stateful/hooks/useQueryLoadingData.ts b/packages/stateful/hooks/useQueryLoadingData.ts new file mode 100644 index 000000000..0d1d6051c --- /dev/null +++ b/packages/stateful/hooks/useQueryLoadingData.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useDeepCompareMemoize } from 'use-deep-compare-effect' + +import { useUpdatingRef } from '@dao-dao/stateless' +import { LoadingData } from '@dao-dao/types' + +/** + * Transform react-query results into our LoadingData object that components + * use. + */ +export const useQueryLoadingData = ( + /** + * Query options to passthrough to useQuery. + */ + options: Omit>[0], 'select'>, + /** + * Default value in case of an error. + */ + defaultValue: T, + /** + * Optionally call a function on error. + */ + onError?: (error: Error) => void +): LoadingData => { + const { isPending, isError, isRefetching, data, error } = useQuery(options) + + const onErrorRef = useUpdatingRef(onError) + + // Use deep compare to prevent memoize on every re-render if an object is + // passed as the default value. + const memoizedDefaultValue = useDeepCompareMemoize(defaultValue) + + return useMemo((): LoadingData => { + if (isPending) { + return { + loading: true, + } + } else if (isError) { + onErrorRef.current?.(error) + return { + loading: false, + data: memoizedDefaultValue, + } + } else { + return { + loading: false, + updating: isRefetching, + data, + } + } + }, [ + isPending, + isError, + onErrorRef, + error, + memoizedDefaultValue, + isRefetching, + data, + ]) +} diff --git a/packages/stateful/hooks/useQueryLoadingDataWithError.ts b/packages/stateful/hooks/useQueryLoadingDataWithError.ts new file mode 100644 index 000000000..1d309cd1a --- /dev/null +++ b/packages/stateful/hooks/useQueryLoadingDataWithError.ts @@ -0,0 +1,56 @@ +import { QueryKey, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { useUpdatingRef } from '@dao-dao/stateless' +import { LoadingDataWithError } from '@dao-dao/types' + +/** + * Transform react-query results into our LoadingDataWithError abstraction that + * components use. + */ +export const useQueryLoadingDataWithError = < + TQueryFnData extends unknown, + TQueryKey extends QueryKey = QueryKey, + TTransformedData extends unknown = TQueryFnData +>( + /** + * Query options to passthrough to useQuery. + */ + options: Omit< + Parameters< + typeof useQuery + >[0], + 'select' + >, + /** + * Optional function to transform the data. + */ + transform?: (data: TQueryFnData) => TTransformedData +): LoadingDataWithError => { + const { isPending, isError, isRefetching, data, error } = useQuery(options) + const transformRef = useUpdatingRef(transform) + + return useMemo((): LoadingDataWithError => { + if (isPending) { + return { + loading: true, + errored: false, + } + } else if (isError) { + return { + loading: false, + errored: true, + error, + } + } else { + return { + loading: false, + errored: false, + updating: isRefetching, + data: transformRef.current + ? transformRef.current(data) + : (data as unknown as TTransformedData), + } + } + }, [isPending, isError, isRefetching, data, error, transformRef]) +} diff --git a/packages/stateful/hooks/useRefreshProfile.ts b/packages/stateful/hooks/useRefreshProfile.ts index 79b4e2f45..4612dfc77 100644 --- a/packages/stateful/hooks/useRefreshProfile.ts +++ b/packages/stateful/hooks/useRefreshProfile.ts @@ -1,7 +1,8 @@ +import { useQueryClient } from '@tanstack/react-query' import uniq from 'lodash.uniq' -import { useRecoilCallback } from 'recoil' +import { useCallback } from 'react' -import { refreshWalletProfileAtom } from '@dao-dao/state/recoil' +import { useUpdatingRef } from '@dao-dao/stateless' import { LoadingData, UnifiedProfile } from '@dao-dao/types' import { toBech32Hash } from '@dao-dao/utils' @@ -15,28 +16,41 @@ import { toBech32Hash } from '@dao-dao/utils' export const useRefreshProfile = ( address: string | string[], profile: LoadingData -) => - useRecoilCallback( - ({ set }) => - () => { - // Refresh all hashes in the profile(s). This ensures updates made by - // one public key propagate to the other public keys in the profile(s). - const hashes = uniq( - [ - ...[address].flat(), - ...(profile.loading - ? [] - : [profile.data] - .flat() - .flatMap((profile) => - Object.values(profile.chains).map(({ address }) => address) - )), - ].flatMap((address) => toBech32Hash(address) || []) - ) +) => { + const queryClient = useQueryClient() - hashes.forEach((hash) => - set(refreshWalletProfileAtom(hash), (id) => id + 1) - ) - }, - [profile] - ) + // Stabilize reference so callback doesn't change. The latest values will be + // used when refresh is called. + const addressRef = useUpdatingRef(address) + const profileRef = useUpdatingRef(profile) + + return useCallback(() => { + // Refresh all hashes in the profile(s). This ensures updates made by + // one public key propagate to the other public keys in the profile(s). + const hashes = uniq( + [ + ...[addressRef.current].flat(), + ...(profileRef.current.loading + ? [] + : [profileRef.current.data] + .flat() + .flatMap((profile) => + Object.values(profile.chains).map(({ address }) => address) + )), + ].flatMap((address) => toBech32Hash(address) || []) + ) + + hashes.forEach((bech32Hash) => + queryClient.invalidateQueries({ + queryKey: [ + { + category: 'profile', + options: { + bech32Hash, + }, + }, + ], + }) + ) + }, [addressRef, profileRef, queryClient]) +} diff --git a/packages/stateful/index.ts b/packages/stateful/index.ts index e67305479..7debc3022 100644 --- a/packages/stateful/index.ts +++ b/packages/stateful/index.ts @@ -1,4 +1,5 @@ export * from './components' export * from './hooks' +export * from './queries' export * from './recoil' export * from './utils' diff --git a/packages/stateful/package.json b/packages/stateful/package.json index ca8ccb9a6..34eee5c30 100644 --- a/packages/stateful/package.json +++ b/packages/stateful/package.json @@ -47,6 +47,7 @@ "@emotion/styled": "^11.10.4", "@mui/icons-material": "^5.10.3", "@mui/material": "^5.10.3", + "@tanstack/react-query": "^5.40.0", "@walletconnect/browser-utils": "^1.8.0", "buffer": "^6.0.3", "chain-registry": "^1.59.4", diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/functions/fetchPrePropose.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/functions/fetchPrePropose.ts index c8ece500a..633889bb5 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/functions/fetchPrePropose.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/functions/fetchPrePropose.ts @@ -11,6 +11,7 @@ import { } from '@dao-dao/utils' export const fetchPrePropose: FetchPreProposeFunction = async ( + queryClient, chainId, proposalModuleAddress, version @@ -55,5 +56,5 @@ export const fetchPrePropose: FetchPreProposeFunction = async ( return null } - return await fetchPreProposeModule(chainId, preProposeAddress) + return await fetchPreProposeModule(queryClient, chainId, preProposeAddress) } diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/functions/fetchPrePropose.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/functions/fetchPrePropose.ts index bc8341bf1..716ed5468 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/functions/fetchPrePropose.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/functions/fetchPrePropose.ts @@ -11,6 +11,7 @@ import { } from '@dao-dao/utils' export const fetchPrePropose: FetchPreProposeFunction = async ( + queryClient, chainId, proposalModuleAddress, version @@ -56,5 +57,5 @@ export const fetchPrePropose: FetchPreProposeFunction = async ( return null } - return await fetchPreProposeModule(chainId, preProposeAddress) + return await fetchPreProposeModule(queryClient, chainId, preProposeAddress) } diff --git a/packages/stateful/queries/dao.ts b/packages/stateful/queries/dao.ts new file mode 100644 index 000000000..1fe6dfed5 --- /dev/null +++ b/packages/stateful/queries/dao.ts @@ -0,0 +1,451 @@ +import { QueryClient, queryOptions, skipToken } from '@tanstack/react-query' + +import { + accountQueries, + chainQueries, + contractQueries, + polytoneQueries, + votingModuleQueries, +} from '@dao-dao/state/query' +import { daoDaoCoreQueries } from '@dao-dao/state/query/queries/contracts/DaoDaoCore' +import { + DaoInfo, + DaoParentInfo, + DaoSource, + Feature, + InfoResponse, + PolytoneProxies, + ProposalModule, +} from '@dao-dao/types' +import { + getDaoInfoForChainId, + getFallbackImage, + getSupportedChainConfig, + getSupportedFeatures, + isConfiguredChainName, + isFeatureSupportedByVersion, + parseContractVersion, +} from '@dao-dao/utils' + +import { fetchProposalModules } from '../utils' + +/** + * Fetch DAO proposal modules. + */ +export const fetchDaoProposalModules = async ( + queryClient: QueryClient, + { chainId, coreAddress }: DaoSource +): Promise => { + const coreVersion = parseContractVersion( + ( + await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: coreAddress, + }) + ) + ).info.version + ) + + return await fetchProposalModules( + queryClient, + chainId, + coreAddress, + coreVersion + ) +} + +/** + * Fetch DAO info. + */ +export const fetchDaoInfo = async ( + queryClient: QueryClient, + { chainId, coreAddress }: DaoSource +): Promise => { + // Native chain governance. + if (isConfiguredChainName(chainId, coreAddress)) { + // Use real gov DAO's address if exists. + const chainConfigGovAddress = + getSupportedChainConfig(chainId)?.govContractAddress + if (chainConfigGovAddress) { + coreAddress = chainConfigGovAddress + } else { + // Use chain x/gov module info. + const govModuleAddress = await queryClient.fetchQuery( + chainQueries.moduleAddress({ + chainId, + name: 'gov', + }) + ) + const accounts = await queryClient.fetchQuery( + accountQueries.list(queryClient, { + chainId, + address: govModuleAddress, + }) + ) + + return getDaoInfoForChainId(chainId, accounts) + } + } + + // Get DAO info from contract. + + const state = await queryClient.fetchQuery( + daoDaoCoreQueries.dumpState(queryClient, { + chainId, + contractAddress: coreAddress, + }) + ) + + const coreVersion = parseContractVersion(state.version.version) + const supportedFeatures = getSupportedFeatures(coreVersion) + + const [ + parentDao, + votingModuleInfo, + created, + proposalModules, + _items, + polytoneProxies, + accounts, + isActive, + activeThreshold, + ] = await Promise.all([ + state.admin && state.admin !== coreAddress + ? queryClient + .fetchQuery( + daoQueries.parentInfo(queryClient, { + chainId, + parentAddress: state.admin, + subDaoAddress: coreAddress, + }) + ) + .catch(() => null) + : null, + // Check if indexer returned this already. + 'votingModuleInfo' in state + ? ({ info: state.votingModuleInfo } as InfoResponse) + : queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: state.voting_module, + }) + ), + // Check if indexer returned this already. + 'createdAt' in state && state.createdAt + ? Date.parse(state.createdAt) + : queryClient + .fetchQuery( + contractQueries.instantiationTime(queryClient, { + chainId, + address: coreAddress, + }) + ) + .catch(() => null), + queryClient.fetchQuery( + daoQueries.proposalModules(queryClient, { + chainId, + coreAddress, + }) + ), + queryClient.fetchQuery( + daoDaoCoreQueries.listAllItems(queryClient, { + chainId, + contractAddress: coreAddress, + }) + ), + // Check if indexer returned this already. + 'polytoneProxies' in state && state.polytoneProxies + ? (state.polytoneProxies as PolytoneProxies) + : queryClient.fetchQuery( + polytoneQueries.proxies(queryClient, { + chainId, + address: coreAddress, + }) + ), + queryClient.fetchQuery( + accountQueries.list(queryClient, { + chainId, + address: coreAddress, + }) + ), + + // Some voting modules don't support the active threshold queries, so if the + // queries fail, assume active and no threshold. + queryClient + .fetchQuery( + votingModuleQueries.isActive({ + chainId, + address: state.voting_module, + }) + ) + // If isActive query fails, just assume it is. + .catch(() => true), + queryClient + .fetchQuery( + votingModuleQueries.activeThresold(queryClient, { + chainId, + address: state.voting_module, + }) + ) + .then(({ active_threshold }) => active_threshold || null) + .catch(() => null), + ]) + + const votingModuleContractName = votingModuleInfo.info.contract + + // Convert items list into map. + const items = Object.fromEntries(_items) + + return { + chainId, + coreAddress, + coreVersion, + supportedFeatures, + votingModuleAddress: state.voting_module, + votingModuleContractName, + proposalModules, + admin: state.admin, + name: state.config.name, + description: state.config.description, + imageUrl: state.config.image_url || getFallbackImage(coreAddress), + created, + isActive, + activeThreshold, + items, + polytoneProxies, + accounts, + parentDao, + } +} + +/** + * Fetch DAO parent info. + */ +export const fetchDaoParentInfo = async ( + queryClient: QueryClient, + { + chainId, + parentAddress, + subDaoAddress, + ignoreParents, + }: { + chainId: string + parentAddress: string + /** + * To determine if the parent has registered the subDAO, pass the subDAO. + * This will set `registeredSubDao` appropriately. Otherwise, if undefined, + * `registeredSubDao` will be set to false. + */ + subDaoAddress?: string + /** + * Prevent infinite loop if DAO SubDAO loop exists. + */ + ignoreParents?: string[] + } +): Promise => { + // If address is a DAO contract... + const isDao = await queryClient.fetchQuery( + contractQueries.isDao(queryClient, { + chainId, + address: parentAddress, + }) + ) + + if (isDao) { + const [parentVersion, parentAdmin, { name, image_url }] = await Promise.all( + [ + queryClient + .fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: parentAddress, + }) + ) + .then(({ info }) => parseContractVersion(info.version)), + queryClient.fetchQuery( + daoDaoCoreQueries.admin(queryClient, { + chainId, + contractAddress: parentAddress, + }) + ), + queryClient.fetchQuery( + daoDaoCoreQueries.config(queryClient, { + chainId, + contractAddress: parentAddress, + }) + ), + ] + ) + + // Check if parent has registered the SubDAO. + const registeredSubDao = + !!subDaoAddress && + isFeatureSupportedByVersion(Feature.SubDaos, parentVersion) && + ( + await queryClient.fetchQuery( + daoDaoCoreQueries.listAllSubDaos(queryClient, { + chainId, + contractAddress: parentAddress, + }) + ) + ).some(({ addr }) => addr === subDaoAddress) + + // Recursively fetch parent. + const parentDao = + parentAdmin && parentAdmin !== parentAddress + ? await queryClient + .fetchQuery( + daoQueries.parentInfo(queryClient, { + chainId, + parentAddress: parentAdmin, + subDaoAddress: parentAddress, + // Add address to ignore list to prevent infinite loops. + ignoreParents: [...(ignoreParents || []), parentAddress], + }) + ) + .catch(() => null) + : null + + return { + chainId, + coreAddress: parentAddress, + coreVersion: parentVersion, + name, + imageUrl: image_url || getFallbackImage(parentAddress), + admin: parentAdmin ?? '', + registeredSubDao, + parentDao, + } + } else { + // If address is the chain's x/gov module... + const isGov = await queryClient.fetchQuery( + chainQueries.isAddressModule(queryClient, { + chainId, + address: parentAddress, + moduleName: 'gov', + }) + ) + if (isGov) { + const chainDaoInfo = getDaoInfoForChainId(chainId, []) + return { + chainId, + coreAddress: chainDaoInfo.coreAddress, + coreVersion: chainDaoInfo.coreVersion, + name: chainDaoInfo.name, + imageUrl: chainDaoInfo.imageUrl, + admin: '', + registeredSubDao: + !!subDaoAddress && + !!getSupportedChainConfig(chainId)?.subDaos?.includes(subDaoAddress), + parentDao: null, + } + } + } + + throw new Error('Parent is not a DAO nor the chain governance module') +} + +/** + * Fetch DAO info for all of a DAO's SubDAOs. + */ +export const fetchSubDaoInfos = async ( + queryClient: QueryClient, + { chainId, coreAddress }: DaoSource +): Promise => { + const subDaos = await queryClient.fetchQuery( + daoDaoCoreQueries.listAllSubDaos(queryClient, { + chainId, + contractAddress: coreAddress, + }) + ) + + return await Promise.all( + subDaos.map(({ addr }) => + queryClient.fetchQuery( + daoQueries.info(queryClient, { chainId, coreAddress: addr }) + ) + ) + ) +} + +/** + * Fetch DAO info for all of a chain's SubDAOs. + */ +export const fetchChainSubDaoInfos = ( + queryClient: QueryClient, + { chainId }: { chainId: string } +): Promise => + Promise.all( + (getSupportedChainConfig(chainId)?.subDaos || []).map((coreAddress) => + queryClient.fetchQuery( + daoQueries.info(queryClient, { chainId, coreAddress }) + ) + ) + ) + +export const daoQueries = { + /** + * Fetch DAO proposal modules. + */ + proposalModules: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['dao', 'proposalModules', options], + queryFn: () => fetchDaoProposalModules(queryClient, options), + }), + /** + * Fetch DAO info. + */ + info: ( + queryClient: QueryClient, + /** + * If undefined, query will be disabled. + */ + options?: Parameters[1] + ) => + queryOptions({ + queryKey: ['dao', 'info', options], + queryFn: options ? () => fetchDaoInfo(queryClient, options) : skipToken, + }), + /** + * Fetch DAO parent info. + */ + parentInfo: ( + queryClient: QueryClient, + /** + * If undefined, query will be disabled. + */ + options?: Parameters[1] + ) => + queryOptions({ + queryKey: ['dao', 'parentInfo', options], + queryFn: options + ? () => fetchDaoParentInfo(queryClient, options) + : skipToken, + }), + /** + * Fetch DAO info for all of a DAO's SubDAOs. + */ + subDaoInfos: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['dao', 'subDaoInfos', options], + queryFn: () => fetchSubDaoInfos(queryClient, options), + }), + /** + * Fetch DAO info for all of a chain's SubDAOs. + */ + chainSubDaoInfos: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['dao', 'chainSubDaoInfos', options], + queryFn: () => fetchChainSubDaoInfos(queryClient, options), + }), +} diff --git a/packages/stateful/queries/entity.ts b/packages/stateful/queries/entity.ts new file mode 100644 index 000000000..4354a61af --- /dev/null +++ b/packages/stateful/queries/entity.ts @@ -0,0 +1,214 @@ +import { QueryClient, queryOptions, skipToken } from '@tanstack/react-query' + +import { + chainQueries, + contractQueries, + cw1WhitelistQueries, + polytoneQueries, + profileQueries, +} from '@dao-dao/state/query' +import { Entity, EntityType } from '@dao-dao/types' +import { + getChainForChainId, + getFallbackImage, + getImageUrlForChainId, + isValidWalletAddress, +} from '@dao-dao/utils' + +import { daoQueries } from './dao' + +/** + * Fetch entity information for any address on any chain. + */ +export const fetchEntityInfo = async ( + queryClient: QueryClient, + { + chainId, + address, + ignoreEntities, + }: { + chainId: string + address: string + /** + * Prevent infinite loop if cw1-whitelist nests itself. + */ + ignoreEntities?: string[] + } +): Promise => { + const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) + + // Check if address is module account. + const moduleName = await queryClient + .fetchQuery( + chainQueries.moduleName({ + chainId, + address, + }) + ) + .catch(() => null) + if (moduleName) { + const entity: Entity = { + type: EntityType.Module, + chainId, + address, + name: moduleName, + imageUrl: getImageUrlForChainId(chainId), + } + return entity + } + + const [ + daoInfo, + entityFromPolytoneProxy, + walletProfile, + cw1WhitelistAdminEntities, + ] = await Promise.all([ + // Attempt to load DAO. + queryClient + .fetchQuery( + contractQueries.isDao(queryClient, { + chainId, + address, + }) + ) + .then((isDao) => + isDao + ? queryClient.fetchQuery( + daoQueries.info(queryClient, { + chainId, + coreAddress: address, + }) + ) + : undefined + ) + .catch(() => undefined), + // Attempt to load polytone proxy. + queryClient + .fetchQuery( + contractQueries.isPolytoneProxy(queryClient, { + chainId, + address, + }) + ) + .then(async (isPolytoneProxy): Promise => { + if (!isPolytoneProxy) { + return + } + + const controller = await queryClient.fetchQuery( + polytoneQueries.reverseLookupProxy(queryClient, { + chainId, + address, + }) + ) + + return { + ...(await queryClient.fetchQuery( + entityQueries.info(queryClient, { + chainId: controller.chainId, + address: controller.remoteAddress, + }) + )), + polytoneProxy: { + chainId, + address, + }, + } + }) + .catch(() => undefined), + // Attempt to load wallet profile. + isValidWalletAddress(address, bech32Prefix) + ? queryClient.fetchQuery( + profileQueries.unified(queryClient, { + chainId, + address, + }) + ) + : undefined, + // Attempt to load cw1-whitelist admins. + queryClient + .fetchQuery( + cw1WhitelistQueries.adminsIfCw1Whitelist(queryClient, { + chainId, + contractAddress: address, + }) + ) + .then((admins): Promise | undefined => { + if (!admins) { + return + } + + return Promise.all( + admins.map((admin) => + ignoreEntities?.includes(admin) + ? // Placeholder entity to prevent infinite loop. + { + chainId, + type: EntityType.Wallet, + address: admin, + name: null, + imageUrl: getFallbackImage(admin), + } + : queryClient.fetchQuery( + entityQueries.info(queryClient, { + chainId, + address: admin, + // Add address to ignore list to prevent infinite loops. + ignoreEntities: [...(ignoreEntities || []), address], + }) + ) + ) + ) + }) + .catch(() => undefined), + ]) + + if (daoInfo) { + return { + type: EntityType.Dao, + daoInfo, + chainId, + address, + name: daoInfo.name, + imageUrl: daoInfo.imageUrl || getFallbackImage(address), + } + } else if (entityFromPolytoneProxy) { + return entityFromPolytoneProxy + } else if (cw1WhitelistAdminEntities) { + return { + type: EntityType.Cw1Whitelist, + chainId, + address, + name: null, + imageUrl: getFallbackImage(address), + entities: cw1WhitelistAdminEntities, + } + } else { + // Default to wallet. + return { + type: EntityType.Wallet, + chainId, + address, + name: walletProfile?.name || null, + imageUrl: walletProfile?.imageUrl || getFallbackImage(address), + profile: walletProfile, + } + } +} + +export const entityQueries = { + /** + * Fetch entity. + */ + info: ( + queryClient: QueryClient, + // If undefined, query will be disabled. + options?: Parameters[1] + ) => + queryOptions({ + queryKey: ['entity', 'info', options], + queryFn: options + ? () => fetchEntityInfo(queryClient, options) + : skipToken, + }), +} diff --git a/packages/stateful/queries/index.ts b/packages/stateful/queries/index.ts new file mode 100644 index 000000000..5ea48df29 --- /dev/null +++ b/packages/stateful/queries/index.ts @@ -0,0 +1,2 @@ +export * from './dao' +export * from './entity' diff --git a/packages/stateful/recoil/selectors/dao.ts b/packages/stateful/recoil/selectors/dao.ts index 112c64872..ed0975dbb 100644 --- a/packages/stateful/recoil/selectors/dao.ts +++ b/packages/stateful/recoil/selectors/dao.ts @@ -11,26 +11,21 @@ import { DaoVotingCw20StakedSelectors, accountsSelector, contractInfoSelector, - contractInstantiateTimeSelector, contractVersionSelector, daoDropdownInfoSelector, - daoParentInfoSelector, daoTvlSelector, daoVetoableDaosSelector, followingDaosSelector, govProposalsSelector, isDaoSelector, - moduleAddressSelector, nativeDelegatedBalanceSelector, + queryClientAtom, queryWalletIndexerSelector, refreshProposalsIdAtom, - reverseLookupPolytoneProxySelector, } from '@dao-dao/state' import { DaoCardLazyData, - DaoInfo, DaoPageMode, - DaoParentInfo, DaoSource, DaoWithDropdownVetoableProposalList, DaoWithVetoableProposals, @@ -41,11 +36,8 @@ import { } from '@dao-dao/types' import { DaoVotingCw20StakedAdapterId, - getDaoInfoForChainId, getDaoProposalPath, - getFallbackImage, getSupportedChainConfig, - getSupportedFeatures, isConfiguredChainName, } from '@dao-dao/utils' @@ -79,6 +71,8 @@ export const daoCardLazyDataSelector = selectorFamily< if (govContractAddress) { coreAddress = govContractAddress } else { + // Use chain x/gov module info. + // Get proposal count by loading one proposal and getting the total. const { total: proposalCount } = get( govProposalsSelector({ @@ -154,58 +148,6 @@ export const daoCardLazyDataSelector = selectorFamily< }, }) -export const subDaoInfosSelector = selectorFamily< - DaoInfo[], - WithChainId<{ coreAddress: string }> ->({ - key: 'subDaoInfos', - get: - ({ coreAddress: contractAddress, chainId }) => - ({ get }) => { - const subDaos = get( - DaoCoreV2Selectors.listAllSubDaosSelector({ - contractAddress, - chainId, - }) - ) - - return get( - waitForAll( - subDaos.map(({ chainId, addr }) => - daoInfoSelector({ - chainId, - coreAddress: addr, - }) - ) - ) - ) - }, -}) - -export const chainSubDaoInfosSelector = selectorFamily< - DaoInfo[], - { chainId: string } ->({ - key: 'chainSubDaoInfos', - get: - ({ chainId }) => - ({ get }) => { - const subDaos = getSupportedChainConfig(chainId)?.subDaos || [] - return subDaos.length - ? get( - waitForAll( - subDaos.map((coreAddress) => - daoInfoSelector({ - chainId, - coreAddress, - }) - ) - ) - ) - : [] - }, -}) - export const followingDaosWithProposalModulesSelector = selectorFamily< (DaoSource & { proposalModules: ProposalModule[] @@ -248,6 +190,7 @@ export const daoCoreProposalModulesSelector = selectorFamily< get: ({ coreAddress, chainId }) => async ({ get }) => { + const queryClient = get(queryClientAtom) const coreVersion = get( contractVersionSelector({ contractAddress: coreAddress, @@ -255,7 +198,12 @@ export const daoCoreProposalModulesSelector = selectorFamily< }) ) - return await fetchProposalModules(chainId, coreAddress, coreVersion) + return await fetchProposalModules( + queryClient, + chainId, + coreAddress, + coreVersion + ) }, }) @@ -312,215 +260,6 @@ export const daoCw20GovernanceTokenAddressSelector = selectorFamily< }, }) -export const daoInfoSelector = selectorFamily< - DaoInfo, - { - chainId: string - coreAddress: string - } ->({ - key: 'daoInfo', - get: - ({ chainId, coreAddress }) => - ({ get }) => { - // Native chain governance. - if (isConfiguredChainName(chainId, coreAddress)) { - // If chain uses a contract-based DAO, load it instead. - const govContractAddress = - getSupportedChainConfig(chainId)?.govContractAddress - if (govContractAddress) { - coreAddress = govContractAddress - } else { - const govModuleAddress = get( - moduleAddressSelector({ - chainId, - name: 'gov', - }) - ) - const accounts = get( - accountsSelector({ - chainId, - address: govModuleAddress, - }) - ) - - return getDaoInfoForChainId(chainId, accounts) - } - } - - // Otherwise get DAO info from contract. - - const dumpState = get( - DaoCoreV2Selectors.dumpStateSelector({ - contractAddress: coreAddress, - chainId, - params: [], - }) - ) - if (!dumpState) { - throw new Error('DAO failed to dump state.') - } - - const [ - // Non-loadables - [ - coreVersion, - votingModuleInfo, - proposalModules, - created, - _items, - polytoneProxies, - accounts, - ], - // Loadables - [isActiveResponse, activeThresholdResponse], - ] = get( - waitForAll([ - // Non-loadables - waitForAll([ - contractVersionSelector({ - contractAddress: coreAddress, - chainId, - }), - contractInfoSelector({ - contractAddress: dumpState.voting_module, - chainId, - }), - daoCoreProposalModulesSelector({ - coreAddress, - chainId, - }), - contractInstantiateTimeSelector({ - address: coreAddress, - chainId, - }), - DaoCoreV2Selectors.listAllItemsSelector({ - contractAddress: coreAddress, - chainId, - }), - DaoCoreV2Selectors.polytoneProxiesSelector({ - contractAddress: coreAddress, - chainId, - }), - accountsSelector({ - address: coreAddress, - chainId, - }), - ]), - // Loadables - waitForAllSettled([ - // All voting modules use the same active threshold queries, so it's - // safe to use the cw20-staked selector. - DaoVotingCw20StakedSelectors.isActiveSelector({ - contractAddress: dumpState.voting_module, - chainId, - params: [], - }), - DaoVotingCw20StakedSelectors.activeThresholdSelector({ - contractAddress: dumpState.voting_module, - chainId, - params: [], - }), - ]), - ]) - ) - - const votingModuleContractName = - votingModuleInfo?.info.contract || 'fallback' - - // Some voting modules don't support the isActive query, so if the query - // fails, assume active. - const isActive = - isActiveResponse.state === 'hasError' || - (isActiveResponse.state === 'hasValue' && - isActiveResponse.contents.active) - const activeThreshold = - (activeThresholdResponse.state === 'hasValue' && - activeThresholdResponse.contents.active_threshold) || - null - - // Convert items list into map. - const items = _items.reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value, - }), - {} as Record - ) - - const { admin } = dumpState - - const parentDao: DaoParentInfo | null = - admin && admin !== coreAddress - ? get( - daoParentInfoSelector({ - chainId, - parentAddress: admin, - childAddress: coreAddress, - }) - ) || null - : null - - const daoInfo: DaoInfo = { - chainId, - coreAddress, - coreVersion, - supportedFeatures: getSupportedFeatures(coreVersion), - votingModuleAddress: dumpState.voting_module, - votingModuleContractName, - proposalModules, - name: dumpState.config.name, - description: dumpState.config.description, - imageUrl: dumpState.config.image_url || getFallbackImage(coreAddress), - created: created?.getTime() || null, - isActive, - activeThreshold, - items, - polytoneProxies, - accounts, - parentDao, - admin, - } - - return daoInfo - }, -}) - -export const daoInfoFromPolytoneProxySelector = selectorFamily< - | { - chainId: string - coreAddress: string - info: DaoInfo - } - | undefined, - WithChainId<{ proxy: string }> ->({ - key: 'daoInfoFromPolytoneProxy', - get: - (params) => - ({ get }) => { - const { chainId, address } = - get(reverseLookupPolytoneProxySelector(params)) ?? {} - if (!chainId || !address) { - return - } - - // Get DAO info on source chain. - const info = get( - daoInfoSelector({ - chainId, - coreAddress: address, - }) - ) - - return { - chainId, - coreAddress: address, - info, - } - }, -}) - /** * Proposals which this DAO can currently veto. */ diff --git a/packages/stateful/recoil/selectors/entity.ts b/packages/stateful/recoil/selectors/entity.ts deleted file mode 100644 index 716efa7c5..000000000 --- a/packages/stateful/recoil/selectors/entity.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - RecoilValueReadOnly, - constSelector, - selectorFamily, - waitForAll, - waitForAllSettled, -} from 'recoil' - -import { - Cw1WhitelistSelectors, - isDaoSelector, - isPolytoneProxySelector, - moduleNameForAddressSelector, - profileSelector, -} from '@dao-dao/state/recoil' -import { Entity, EntityType, WithChainId } from '@dao-dao/types' -import { - getChainForChainId, - getFallbackImage, - getImageUrlForChainId, - isConfiguredChainName, - isValidWalletAddress, -} from '@dao-dao/utils' - -import { daoInfoFromPolytoneProxySelector, daoInfoSelector } from './dao' - -// Load entity from address on chain, whether it's a wallet address, a DAO, or a -// DAO's polytone account. -export const entitySelector: ( - param: WithChainId<{ - address: string - // Prevent infinite loop if cw1-whitelist nests itself. - ignoreEntities?: string[] - }> -) => RecoilValueReadOnly = selectorFamily({ - key: 'entity', - get: - ({ chainId, address, ignoreEntities }) => - ({ get }) => { - const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) - - // Check if address is module account. - const moduleName = get( - waitForAllSettled([ - moduleNameForAddressSelector({ - chainId, - address, - }), - ]) - )[0].valueMaybe() - if (moduleName) { - const entity: Entity = { - type: EntityType.Module, - chainId, - address, - name: moduleName, - imageUrl: getImageUrlForChainId(chainId), - } - return entity - } - - const [isDao, isPolytoneProxy, cw1WhitelistAdmins] = address - ? get( - waitForAllSettled([ - isConfiguredChainName(chainId, address) - ? constSelector(true) - : isDaoSelector({ - chainId, - address, - }), - isPolytoneProxySelector({ - chainId, - address, - }), - Cw1WhitelistSelectors.adminsIfCw1Whitelist({ - chainId, - contractAddress: address, - }), - ]) - ) - : [] - - const [ - daoInfoLoadable, - daoInfoFromPolytoneProxyLoadable, - profileLoadable, - cw1WhitelistEntitiesLoadable, - ] = get( - waitForAllSettled([ - // Try to load config assuming the address is a DAO. - isDao?.state === 'hasValue' && isDao.contents - ? daoInfoSelector({ - chainId, - coreAddress: address, - }) - : constSelector(undefined), - isPolytoneProxy?.state === 'hasValue' && isPolytoneProxy.contents - ? daoInfoFromPolytoneProxySelector({ - chainId, - proxy: address, - }) - : constSelector(undefined), - // Try to load profile assuming the address is a wallet. - address && isValidWalletAddress(address, bech32Prefix) - ? profileSelector({ - chainId, - address, - }) - : constSelector(undefined), - // Try to load all contained entities for cw1-whitelist. - cw1WhitelistAdmins?.state === 'hasValue' && - cw1WhitelistAdmins.contents - ? waitForAll( - cw1WhitelistAdmins.contents.map((entityAddress) => - ignoreEntities?.includes(entityAddress) - ? // Placeholder entity to prevent infinite loop. - constSelector({ - chainId, - type: EntityType.Wallet, - address: entityAddress, - name: null, - imageUrl: getFallbackImage(entityAddress), - } as const) - : entitySelector({ - chainId, - address: entityAddress, - // Prevent infinite loop if cw1-whitelist nests itself. - ignoreEntities: [...(ignoreEntities || []), address], - }) - ) - ) - : constSelector(undefined), - ]) - ) - - const daoInfo = daoInfoLoadable.valueMaybe() - const daoInfoFromPolytoneProxy = - daoInfoFromPolytoneProxyLoadable.valueMaybe() - const profile = profileLoadable.valueMaybe() - const cw1WhitelistEntities = cw1WhitelistEntitiesLoadable.valueMaybe() - - if (daoInfo) { - return { - type: EntityType.Dao, - daoInfo, - chainId, - address, - name: daoInfo.name, - imageUrl: daoInfo.imageUrl || getFallbackImage(address), - } - } else if (daoInfoFromPolytoneProxy) { - return { - type: EntityType.Dao, - daoInfo: daoInfoFromPolytoneProxy.info, - polytoneProxy: { - chainId, - address, - }, - chainId: daoInfoFromPolytoneProxy.chainId, - address: daoInfoFromPolytoneProxy.coreAddress, - name: daoInfoFromPolytoneProxy.info.name, - imageUrl: - daoInfoFromPolytoneProxy.info.imageUrl || - getFallbackImage(daoInfoFromPolytoneProxy.coreAddress), - } - } else if (cw1WhitelistEntities) { - return { - type: EntityType.Cw1Whitelist, - chainId, - address, - name: null, - imageUrl: getFallbackImage(address), - entities: cw1WhitelistEntities, - } - } else { - return { - type: EntityType.Wallet, - chainId, - address, - name: profile?.name || null, - imageUrl: profile?.imageUrl || getFallbackImage(address), - profile: profile, - } - } - }, -}) diff --git a/packages/stateful/recoil/selectors/index.ts b/packages/stateful/recoil/selectors/index.ts index 5c0012a46..8eb7e6071 100644 --- a/packages/stateful/recoil/selectors/index.ts +++ b/packages/stateful/recoil/selectors/index.ts @@ -1,3 +1,2 @@ export * from './dao' -export * from './entity' export * from './proposal' diff --git a/packages/stateful/server/makeGetDaoStaticProps.ts b/packages/stateful/server/makeGetDaoStaticProps.ts index f901eeb67..06616a293 100644 --- a/packages/stateful/server/makeGetDaoStaticProps.ts +++ b/packages/stateful/server/makeGetDaoStaticProps.ts @@ -1,72 +1,45 @@ import { Chain } from '@chain-registry/types' import { fromBase64 } from '@cosmjs/encoding' +import { QueryClient } from '@tanstack/react-query' import type { GetStaticProps, GetStaticPropsResult, Redirect } from 'next' import { TFunction } from 'next-i18next' import removeMarkdown from 'remove-markdown' import { serverSideTranslationsWithServerT } from '@dao-dao/i18n/serverSideTranslations' import { - DaoCoreV2QueryClient, - DaoVotingCw20StakedQueryClient, - PolytoneNoteQueryClient, + contractQueries, + dehydrateSerializable, + makeReactQueryClient, + polytoneQueries, queryIndexer, } from '@dao-dao/state' import { - Account, - AccountType, - ActiveThreshold, ChainId, CommonProposalInfo, ContractVersion, - ContractVersionInfo, + DaoInfo, DaoPageMode, - DaoParentInfo, - Feature, GovProposalVersion, GovProposalWithDecodedContent, - IndexerDumpState, - InfoResponse, - PolytoneProxies, - ProposalModule, ProposalV1, ProposalV1Beta1, - SupportedFeatureMap, } from '@dao-dao/types' -import { - Config, - ListItemsResponse, - ProposalModuleWithInfo, -} from '@dao-dao/types/contracts/DaoCore.v2' import { cosmos } from '@dao-dao/types/protobuf' import { CI, - ContractName, DAO_CORE_ACCENT_ITEM_KEY, DAO_STATIC_PROPS_CACHE_SECONDS, - INVALID_CONTRACT_ERROR_SUBSTRINGS, LEGACY_DAO_CONTRACT_NAMES, LEGACY_URL_PREFIX, MAINNET, MAX_META_CHARS_PROPOSAL_DESCRIPTION, - addressIsModule, - cosmWasmClientRouter, cosmosSdkVersionIs46OrHigher, decodeGovProposal, getChainForChainId, - getChainGovernanceDaoDescription, getChainIdForAddress, getConfiguredGovChainByName, getDaoPath, - getDisplayNameForChainId, - getFallbackImage, - getImageUrlForChainId, getRpcForChainId, - getSupportedChainConfig, - getSupportedFeatures, - isFeatureSupportedByVersion, - isValidBech32Address, - parseContractVersion, - polytoneNoteProxyMapToChainIdMap, processError, retry, } from '@dao-dao/utils' @@ -76,17 +49,13 @@ import { ProposalModuleAdapterError, matchAndLoadAdapter, } from '../proposal-module-adapter' -import { - fetchProposalModules, - fetchProposalModulesWithInfoFromChain, -} from '../utils/fetchProposalModules' +import { daoQueries } from '../queries/dao' interface GetDaoStaticPropsMakerProps { leadingTitle?: string followingTitle?: string overrideTitle?: string overrideDescription?: string - overrideImageUrl?: string additionalProps?: Record | null | undefined url?: string } @@ -97,10 +66,9 @@ interface GetDaoStaticPropsMakerOptions { getProps?: (options: { context: Parameters[0] t: TFunction + queryClient: QueryClient chain: Chain - coreAddress: string - coreVersion: ContractVersion - proposalModules: ProposalModule[] + daoInfo: DaoInfo }) => | GetDaoStaticPropsMakerProps | undefined @@ -138,238 +106,72 @@ export const makeGetDaoStaticProps: GetDaoStaticPropsMaker = // Check if address is actually the name of a chain so we can resolve the // gov module. - let chainConfig = + const configuredGovChain = coreAddress && typeof coreAddress === 'string' ? getConfiguredGovChainByName(coreAddress) : undefined - // If chain uses a contract-based DAO, load it instead. - const govContractAddress = - chainConfig && - getSupportedChainConfig(chainConfig.chainId)?.govContractAddress - if (govContractAddress) { - coreAddress = govContractAddress - chainConfig = undefined - } - - // Load chain gov module. - if (chainConfig) { - const { name: chainName, chain, accentColor } = chainConfig - - // Must be called after server side translations has been awaited, because - // props may use the `t` function, and it won't be available until after. - const { - leadingTitle, - followingTitle, - overrideTitle, - overrideDescription, - additionalProps, - url, - } = - (await getProps?.({ - context, - t: serverT, - chain, - coreAddress: chainName, - coreVersion: ContractVersion.Gov, - proposalModules: [], - })) ?? {} - - const description = - overrideDescription ?? getChainGovernanceDaoDescription(chain.chain_id) - const props: DaoPageWrapperProps = { - ...i18nProps, - url: url ?? null, - title: - overrideTitle ?? - [ - leadingTitle?.trim(), - getDisplayNameForChainId(chain.chain_id), - followingTitle?.trim(), - ] - .filter(Boolean) - .join(' | '), - description, - accentColor, - info: { - chainId: chain.chain_id, - coreAddress: chainName, - coreVersion: ContractVersion.Gov, - supportedFeatures: Object.values(Feature).reduce( - (acc, feature) => ({ - ...acc, - [feature]: false, - }), - {} as SupportedFeatureMap - ), - votingModuleAddress: '', - votingModuleContractName: '', - proposalModules: [], - name: getDisplayNameForChainId(chain.chain_id), - description, - imageUrl: getImageUrlForChainId(chain.chain_id), - created: null, - isActive: true, - activeThreshold: null, - items: {}, - polytoneProxies: {}, - accounts: [ - { - type: AccountType.Native, - chainId: chain.chain_id, - address: chainConfig.name, - }, - ], - parentDao: null, - admin: '', - }, - ...additionalProps, - } - - return { - props, - // No need to regenerate this page as the props for a chain's DAO are - // constant. The values above can only change when a new version of the - // frontend is deployed, in which case the static pages will regenerate. - revalidate: false, - } - } - - // Get chain ID for address based on prefix. - let decodedChainId: string - try { - // If invalid address, display not found. - if (!coreAddress || typeof coreAddress !== 'string') { - throw new Error('Invalid address') - } - - decodedChainId = getChainIdForAddress(coreAddress) - - // Validation throws error if address prefix not recognized. Display not - // found in this case. - } catch (err) { - console.error(err) - - // Excluding `info` will render DAONotFound. - return { - props: { - ...i18nProps, - title: serverT('title.daoNotFound'), - description: err instanceof Error ? err.message : `${err}`, - }, - } - } + const queryClient = makeReactQueryClient() const getForChainId = async ( chainId: string ): Promise> => { // If address is polytone proxy, redirect to DAO on native chain. - try { - const addressInfo = await queryIndexer({ - type: 'contract', - chainId, - address: coreAddress, - formula: 'info', - }) - if ( - addressInfo && - addressInfo.contract === ContractName.PolytoneProxy - ) { - // Get voice for this proxy on destination chain. - const voice = await queryIndexer({ - type: 'contract', - chainId, - // proxy - address: coreAddress, - formula: 'polytone/proxy/instantiator', - }) - - const dao = await queryIndexer({ - type: 'contract', - chainId, - address: voice, - formula: 'polytone/voice/remoteController', - args: { - // proxy + if (!configuredGovChain) { + try { + const isPolytoneProxy = await queryClient.fetchQuery( + contractQueries.isPolytoneProxy(queryClient, { + chainId, address: coreAddress, - }, - }) + }) + ) + if (isPolytoneProxy) { + const { remoteAddress } = await queryClient.fetchQuery( + polytoneQueries.reverseLookupProxy(queryClient, { + chainId, + address: coreAddress, + }) + ) - return { - redirect: { - destination: getDaoPath(appMode, dao), - permanent: true, - }, + return { + redirect: { + destination: getDaoPath(appMode, remoteAddress), + permanent: true, + }, + } } + } catch { + // If failed, ignore. } - } catch { - // If failed, ignore. } // Add to Sentry error tags if error occurs. - let coreVersion: ContractVersion | undefined + let daoInfo: DaoInfo | undefined try { - const { - admin, - config, - version, - votingModule: { - address: votingModuleAddress, - info: votingModuleInfo, - }, - activeProposalModules, - created, - isActive, - activeThreshold, - parentDao, - items: _items, - polytoneProxies, - } = await daoCoreDumpState(chainId, coreAddress, serverT) - coreVersion = version - - // If no contract name, will display fallback voting module adapter. - const votingModuleContractName = - (votingModuleInfo && - 'contract' in votingModuleInfo && - votingModuleInfo.contract) || - 'fallback' - - // Get DAO proposal modules. - const proposalModules = await fetchProposalModules( - chainId, - coreAddress, - coreVersion, - activeProposalModules - ) - - // Convert items list into map. - const items = _items.reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value, - }), - {} as Record - ) + // Check for legacy contract. + const contractInfo = !configuredGovChain + ? ( + await queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: coreAddress, + }) + ) + )?.info + : undefined + if ( + contractInfo && + LEGACY_DAO_CONTRACT_NAMES.includes(contractInfo.contract) + ) { + throw new LegacyDaoError() + } - const accounts: Account[] = [ - // Current chain. - { + daoInfo = await queryClient.fetchQuery( + daoQueries.info(queryClient, { chainId, - address: coreAddress, - type: AccountType.Native, - }, - // Polytone. - ...Object.entries(polytoneProxies).map( - ([chainId, address]): Account => ({ - chainId, - address, - type: AccountType.Polytone, - }) - ), - // The above accounts are the ones we already have. The rest of the - // accounts are loaded once the page loads (in `DaoPageWrapper`) since - // they are more complex and will probably expand over time. - ] + coreAddress, + }) + ) // Must be called after server side translations has been awaited, // because props may use the `t` function, and it won't be available @@ -379,60 +181,52 @@ export const makeGetDaoStaticProps: GetDaoStaticPropsMaker = followingTitle, overrideTitle, overrideDescription, - overrideImageUrl, additionalProps, url, } = (await getProps?.({ context, t: serverT, + queryClient, chain: getChainForChainId(chainId), - coreAddress, - coreVersion, - proposalModules, + daoInfo, })) ?? {} + const title = + overrideTitle ?? + [leadingTitle?.trim(), daoInfo.name, followingTitle?.trim()] + .filter(Boolean) + .join(' | ') + const description = overrideDescription ?? daoInfo.description + const accentColor = + // If viewing configured gov chain, use its accent color. + configuredGovChain?.accentColor || + daoInfo.items[DAO_CORE_ACCENT_ITEM_KEY] || + null + const props: DaoPageWrapperProps = { ...i18nProps, url: url ?? null, - title: - overrideTitle ?? - [leadingTitle?.trim(), config.name.trim(), followingTitle?.trim()] - .filter(Boolean) - .join(' | '), - description: overrideDescription ?? config.description, - accentColor: items[DAO_CORE_ACCENT_ITEM_KEY] || null, - info: { - chainId, - coreAddress, - coreVersion, - supportedFeatures: getSupportedFeatures(coreVersion), - votingModuleAddress, - votingModuleContractName, - proposalModules, - name: config.name, - description: config.description, - imageUrl: - overrideImageUrl || - config.image_url || - getFallbackImage(coreAddress), - created: created?.getTime() ?? null, - isActive, - activeThreshold, - items, - polytoneProxies, - accounts, - parentDao, - admin: admin ?? null, - }, + title, + description, + accentColor, + info: daoInfo, + reactQueryDehydratedState: dehydrateSerializable(queryClient), ...additionalProps, } return { props, - // Regenerate the page at most once per `revalidate` seconds. Serves - // cached copy and refreshes in background. - revalidate: DAO_STATIC_PROPS_CACHE_SECONDS, + // For chain governance DAOs: no need to regenerate this page for + // since the props are constant. The values above can only change when + // a new version of the frontend is deployed, in which case the static + // pages will regenerate. + // + // For real DAOs, revalidate the page at most once per `revalidate` + // seconds. Serves cached copy and refreshes in background. + revalidate: configuredGovChain + ? false + : DAO_STATIC_PROPS_CACHE_SECONDS, } } catch (error) { // Redirect. @@ -476,6 +270,7 @@ export const makeGetDaoStaticProps: GetDaoStaticPropsMaker = ...i18nProps, title: 'DAO not found', description: '', + reactQueryDehydratedState: dehydrateSerializable(queryClient), }, // Regenerate the page at most once per second. Serves cached copy // and refreshes in background. @@ -489,11 +284,13 @@ export const makeGetDaoStaticProps: GetDaoStaticPropsMaker = ...i18nProps, title: serverT('title.500'), description: '', + reactQueryDehydratedState: dehydrateSerializable(queryClient), // Report to Sentry. error: processError(error, { tags: { + chainId, coreAddress, - coreVersion: coreVersion ?? '', + coreVersion: daoInfo?.coreVersion ?? '', }, extra: { context }, }), @@ -505,21 +302,52 @@ export const makeGetDaoStaticProps: GetDaoStaticPropsMaker = } } - const result = await getForChainId(decodedChainId) + let result + if (configuredGovChain) { + result = await getForChainId(configuredGovChain.chainId) + } else { + // Get chain ID for address based on prefix. + let decodedChainId: string + try { + // If invalid address, display not found. + if (!coreAddress || typeof coreAddress !== 'string') { + throw new Error('Invalid address') + } + + decodedChainId = getChainIdForAddress(coreAddress) + + // Validation throws error if address prefix not recognized. Display not + // found in this case. + } catch (err) { + console.error(err) - // If not found on Terra, try Terra Classic. Let redirects and errors - // through. - if ( - MAINNET && - 'props' in result && - // If no info, no DAO found. - !result.props.info && - // Don't try Terra Classic if unexpected error occurred. - !result.props.error && - // Only try Terra Classic if Terra failed. - decodedChainId === ChainId.TerraMainnet - ) { - return await getForChainId(ChainId.TerraClassicMainnet) + // Excluding `info` will render DAONotFound. + return { + props: { + ...i18nProps, + title: serverT('title.daoNotFound'), + description: err instanceof Error ? err.message : `${err}`, + reactQueryDehydratedState: dehydrateSerializable(queryClient), + }, + } + } + + result = await getForChainId(decodedChainId) + + // If not found on Terra, try Terra Classic. Let redirects and errors + // through. + if ( + MAINNET && + 'props' in result && + // If no info, no DAO found. + !result.props.info && + // Don't try Terra Classic if unexpected error occurred. + !result.props.error && + // Only try Terra Classic if Terra failed. + decodedChainId === ChainId.TerraMainnet + ) { + result = await getForChainId(ChainId.TerraClassicMainnet) + } } return result @@ -540,14 +368,7 @@ export const makeGetDaoProposalStaticProps = ({ }: GetDaoProposalStaticPropsMakerOptions) => makeGetDaoStaticProps({ ...options, - getProps: async ({ - context: { params = {} }, - t, - chain, - coreVersion, - coreAddress, - proposalModules, - }) => { + getProps: async ({ context: { params = {} }, t, chain, daoInfo }) => { const proposalId = params[proposalIdParamKey] // If invalid proposal ID, not found. @@ -561,7 +382,7 @@ export const makeGetDaoProposalStaticProps = ({ } // Gov module. - if (coreVersion === ContractVersion.Gov) { + if (daoInfo.coreVersion === ContractVersion.Gov) { const url = getProposalUrlPrefix(params) + proposalId const client = await retry( @@ -726,9 +547,9 @@ export const makeGetDaoProposalStaticProps = ({ adapter: { functions: { getProposalInfo }, }, - } = await matchAndLoadAdapter(proposalModules, proposalId, { + } = await matchAndLoadAdapter(daoInfo.proposalModules, proposalId, { chain, - coreAddress, + coreAddress: daoInfo.coreAddress, }) // If proposal is numeric, i.e. has no prefix, redirect to prefixed URL. @@ -778,410 +599,3 @@ export const makeGetDaoProposalStaticProps = ({ export class RedirectError { constructor(public redirect: Redirect) {} } - -const loadParentDaoInfo = async ( - chainId: string, - subDaoAddress: string, - potentialParentAddress: string | null | undefined, - serverT: TFunction, - // Prevent cycles by ensuring admin has not already been seen. - previousParentAddresses: string[] -): Promise | null> => { - // If no admin, or admin is set to itself, or admin is a wallet, no parent - // DAO. - if ( - !potentialParentAddress || - potentialParentAddress === subDaoAddress || - previousParentAddresses?.includes(potentialParentAddress) - ) { - return null - } - - try { - // Check if address is chain module account. - const cosmosClient = await retry( - 10, - async (attempt) => - ( - await cosmos.ClientFactory.createRPCQueryClient({ - rpcEndpoint: getRpcForChainId(chainId, attempt - 1), - }) - ).cosmos - ) - // If chain module gov account... - if (await addressIsModule(cosmosClient, potentialParentAddress, 'gov')) { - const chainConfig = getSupportedChainConfig(chainId) - return chainConfig - ? { - chainId, - coreAddress: chainConfig.name, - coreVersion: ContractVersion.Gov, - name: getDisplayNameForChainId(chainId), - imageUrl: getImageUrlForChainId(chainId), - parentDao: null, - admin: '', - } - : null - } - - if ( - !isValidBech32Address( - potentialParentAddress, - getChainForChainId(chainId).bech32_prefix - ) - ) { - return null - } - - const { - admin, - version, - config: { name, image_url }, - parentDao, - } = await daoCoreDumpState(chainId, potentialParentAddress, serverT, [ - ...(previousParentAddresses ?? []), - potentialParentAddress, - ]) - - return { - chainId, - coreAddress: potentialParentAddress, - coreVersion: version, - name: name, - imageUrl: image_url ?? null, - parentDao, - admin: admin ?? null, - } - } catch (err) { - // If contract not found, ignore error. Otherwise, log it. - if ( - !(err instanceof Error) || - !INVALID_CONTRACT_ERROR_SUBSTRINGS.some((substring) => - (err as Error).message.includes(substring) - ) - ) { - console.error(err) - console.error( - `Error loading parent DAO (${potentialParentAddress}) of ${subDaoAddress}`, - processError(err) - ) - } - - // Don't prevent page render if failed to load parent DAO info. - return null - } -} - -const ITEM_LIST_LIMIT = 30 - -interface DaoCoreDumpState { - admin: string - config: Config - version: ContractVersion - votingModule: { - address: string - info: ContractVersionInfo - } - activeProposalModules: ProposalModuleWithInfo[] - created: Date | undefined - parentDao: DaoParentInfo | null - items: ListItemsResponse - polytoneProxies: PolytoneProxies - isActive: boolean - activeThreshold: ActiveThreshold | null -} - -const daoCoreDumpState = async ( - chainId: string, - coreAddress: string, - serverT: TFunction, - // Prevent cycles by ensuring admin has not already been seen. - previousParentAddresses?: string[] -): Promise => { - const cwClient = await retry( - 10, - async (attempt) => - await cosmWasmClientRouter.connect(getRpcForChainId(chainId, attempt - 1)) - ) - - try { - const [indexerDumpedState, items] = await Promise.all([ - queryIndexer({ - type: 'contract', - address: coreAddress, - formula: 'daoCore/dumpState', - chainId, - }), - queryIndexer({ - type: 'contract', - address: coreAddress, - formula: 'daoCore/listItems', - chainId, - }), - ]) - - // Use data from indexer if present. - if (indexerDumpedState) { - if ( - LEGACY_DAO_CONTRACT_NAMES.includes(indexerDumpedState.version?.contract) - ) { - throw new LegacyDaoError() - } - - const coreVersion = parseContractVersion( - indexerDumpedState.version.version - ) - if (!coreVersion) { - throw new Error(serverT('error.failedParsingCoreVersion')) - } - - const { admin } = indexerDumpedState - - const parentDaoInfo = await loadParentDaoInfo( - chainId, - coreAddress, - admin, - serverT, - [...(previousParentAddresses ?? []), coreAddress] - ) - - // Convert to chainId -> proxy map. - const polytoneProxies = polytoneNoteProxyMapToChainIdMap( - chainId, - indexerDumpedState.polytoneProxies || {} - ) - - let isActive = true - let activeThreshold: ActiveThreshold | null = null - try { - // All voting modules use the same active queries, so it's safe to just - // use one here. - const client = new DaoVotingCw20StakedQueryClient( - cwClient, - indexerDumpedState.voting_module - ) - isActive = (await client.isActive()).active - activeThreshold = - (await client.activeThreshold()).active_threshold || null - } catch { - // Some voting modules don't support the active queries, so if they - // fail, assume it's active. - } - - return { - ...indexerDumpedState, - version: coreVersion, - votingModule: { - address: indexerDumpedState.voting_module, - info: indexerDumpedState.votingModuleInfo, - }, - activeProposalModules: indexerDumpedState.proposal_modules.filter( - ({ status }) => status === 'enabled' || status === 'Enabled' - ), - created: indexerDumpedState.createdAt - ? new Date(indexerDumpedState.createdAt) - : undefined, - isActive, - activeThreshold, - items: items || [], - parentDao: parentDaoInfo - ? { - ...parentDaoInfo, - // Whether or not this parent has registered its child as a - // SubDAO. - registeredSubDao: - indexerDumpedState.adminInfo?.registeredSubDao ?? - (parentDaoInfo.coreVersion === ContractVersion.Gov && - getSupportedChainConfig(chainId)?.subDaos?.includes( - coreAddress - )) ?? - false, - } - : null, - polytoneProxies, - } - } - } catch (error) { - // Rethrow if legacy DAO. - if (error instanceof LegacyDaoError) { - throw error - } - - // Ignore error. Fallback to querying chain below. - console.error(error, processError(error)) - } - - const daoCoreClient = new DaoCoreV2QueryClient(cwClient, coreAddress) - - const dumpedState = await daoCoreClient.dumpState() - if (LEGACY_DAO_CONTRACT_NAMES.includes(dumpedState.version.contract)) { - throw new LegacyDaoError() - } - - const [coreVersion, { info: votingModuleInfo }] = await Promise.all([ - parseContractVersion(dumpedState.version.version), - (await cwClient.queryContractSmart(dumpedState.voting_module, { - info: {}, - })) as InfoResponse, - ]) - - if (!coreVersion) { - throw new Error(serverT('error.failedParsingCoreVersion')) - } - - const proposalModules = await fetchProposalModulesWithInfoFromChain( - chainId, - coreAddress, - coreVersion - ) - - // Get all items. - const items: ListItemsResponse = [] - while (true) { - const _items = await daoCoreClient.listItems({ - startAfter: items[items.length - 1]?.[0], - limit: ITEM_LIST_LIMIT, - }) - if (!_items.length) { - break - } - - items.push(..._items) - - // If we got less than the limit, we've reached the end. - if (_items.length < ITEM_LIST_LIMIT) { - break - } - } - - let isActive = true - let activeThreshold: ActiveThreshold | null = null - try { - // All voting modules use the same active queries, so it's safe to just use - // one here. - const client = new DaoVotingCw20StakedQueryClient( - cwClient, - dumpedState.voting_module - ) - isActive = (await client.isActive()).active - activeThreshold = (await client.activeThreshold()).active_threshold || null - } catch { - // Some voting modules don't support the active queries, so if they fail, - // assume it's active. - } - - const { admin } = dumpedState - const parentDao = await loadParentDaoInfo( - chainId, - coreAddress, - admin, - serverT, - [...(previousParentAddresses ?? []), coreAddress] - ) - let registeredSubDao = false - // If parent DAO exists, check if this DAO is a SubDAO of the parent. - if (parentDao) { - if ( - parentDao.coreVersion !== ContractVersion.Gov && - isFeatureSupportedByVersion(Feature.SubDaos, parentDao.coreVersion) - ) { - const parentDaoCoreClient = new DaoCoreV2QueryClient(cwClient, admin) - - // Get all SubDAOs. - const subdaoAddrs: string[] = [] - while (true) { - const response = await parentDaoCoreClient.listSubDaos({ - startAfter: subdaoAddrs[subdaoAddrs.length - 1], - limit: SUBDAO_LIST_LIMIT, - }) - if (!response?.length) break - - subdaoAddrs.push(...response.map(({ addr }) => addr)) - - // If we have less than the limit of items, we've exhausted them. - if (response.length < SUBDAO_LIST_LIMIT) { - break - } - } - - registeredSubDao = subdaoAddrs.includes(coreAddress) - } else if (parentDao.coreVersion === ContractVersion.Gov) { - registeredSubDao = - !!getSupportedChainConfig(chainId)?.subDaos?.includes(coreAddress) - } - } - - // Get DAO polytone proxies. - const polytoneProxies = ( - await Promise.all( - Object.entries(getSupportedChainConfig(chainId)?.polytone || {}).map( - async ([chainId, { note }]) => { - let proxy - try { - proxy = await queryIndexer({ - type: 'contract', - address: note, - formula: 'polytone/note/remoteAddress', - args: { - address: coreAddress, - }, - chainId, - }) - } catch { - // Ignore error. - } - if (!proxy) { - const polytoneNoteClient = new PolytoneNoteQueryClient( - cwClient, - note - ) - proxy = - (await polytoneNoteClient.remoteAddress({ - localAddress: coreAddress, - })) || undefined - } - - return { - chainId, - proxy, - } - } - ) - ) - ).reduce( - (acc, { chainId, proxy }) => ({ - ...acc, - ...(proxy - ? { - [chainId]: proxy, - } - : {}), - }), - {} as PolytoneProxies - ) - - return { - ...dumpedState, - version: coreVersion, - votingModule: { - address: dumpedState.voting_module, - info: votingModuleInfo, - }, - activeProposalModules: proposalModules.filter( - ({ status }) => status === 'enabled' || status === 'Enabled' - ), - created: undefined, - isActive, - activeThreshold, - items, - parentDao: parentDao - ? { - ...parentDao, - registeredSubDao, - } - : null, - polytoneProxies, - } -} - -const SUBDAO_LIST_LIMIT = 30 diff --git a/packages/stateful/utils/fetchProposalModules.ts b/packages/stateful/utils/fetchProposalModules.ts index 1bd15e1cc..6ec65f889 100644 --- a/packages/stateful/utils/fetchProposalModules.ts +++ b/packages/stateful/utils/fetchProposalModules.ts @@ -1,8 +1,10 @@ +import { QueryClient } from '@tanstack/react-query' + import { CwCoreV1QueryClient, DaoCoreV2QueryClient, } from '@dao-dao/state/contracts' -import { queryIndexer } from '@dao-dao/state/indexer' +import { indexerQueries } from '@dao-dao/state/query' import { ContractVersion, Feature, @@ -24,6 +26,7 @@ import { import { matchAdapter } from '../proposal-module-adapter' export const fetchProposalModules = async ( + queryClient: QueryClient, chainId: string, coreAddress: string, coreVersion: ContractVersion, @@ -33,12 +36,13 @@ export const fetchProposalModules = async ( // Try indexer first. if (!activeProposalModules) { try { - activeProposalModules = await queryIndexer({ - type: 'contract', - address: coreAddress, - formula: 'daoCore/activeProposalModules', - chainId, - }) + activeProposalModules = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress: coreAddress, + formula: 'daoCore/activeProposalModules', + }) + ) } catch (err) { // Ignore error. console.error(err) @@ -70,7 +74,12 @@ export const fetchProposalModules = async ( const [prePropose, veto] = await Promise.allSettled([ // Get pre-propose address if exists. - adapter?.functions.fetchPrePropose?.(chainId, address, version), + adapter?.functions.fetchPrePropose?.( + queryClient, + chainId, + address, + version + ), // Get veto config if exists. adapter?.functions.fetchVetoConfig?.(chainId, address, version), ]) diff --git a/packages/stateless/components/dao/DaoCard.stories.tsx b/packages/stateless/components/dao/DaoCard.stories.tsx index 1bf29733b..d4e040e39 100644 --- a/packages/stateless/components/dao/DaoCard.stories.tsx +++ b/packages/stateless/components/dao/DaoCard.stories.tsx @@ -35,6 +35,7 @@ export const makeDaoInfo = (id = 1): DaoInfo => ({ imageUrl: `/placeholders/${((id + 1) % 5) + 1}.svg`, admin: 'parent', registeredSubDao: true, + parentDao: null, }, supportedFeatures: {} as any, votingModuleAddress: '', diff --git a/packages/stateless/components/dao/DaoCard.tsx b/packages/stateless/components/dao/DaoCard.tsx index b7e3a113f..48825498a 100644 --- a/packages/stateless/components/dao/DaoCard.tsx +++ b/packages/stateless/components/dao/DaoCard.tsx @@ -25,6 +25,7 @@ export const DaoCard = ({ LinkWrapper, showIsMember = true, showingEstimatedUsdValue = true, + showParentDao = true, onMouseOver, onMouseLeave, className, @@ -87,7 +88,7 @@ export const DaoCard = ({ coreAddress={coreAddress} daoName={name} imageUrl={imageUrl} - parentDao={parentDao} + parentDao={showParentDao ? parentDao : null} size="sm" />

{name}

diff --git a/packages/stateless/components/dao/DaoImage.tsx b/packages/stateless/components/dao/DaoImage.tsx index 2d5bce961..79f44ca12 100644 --- a/packages/stateless/components/dao/DaoImage.tsx +++ b/packages/stateless/components/dao/DaoImage.tsx @@ -100,7 +100,7 @@ export const DaoImage = ({ U ): LoadingDataWithError => { const loadable = useCachedLoadable(recoilValue) - - const transformRef = useRef(transform) - transformRef.current = transform + const transformRef = useUpdatingRef(transform) return useMemo(() => { const data = loadableToLoadingDataWithError(loadable) @@ -176,7 +176,7 @@ export const useCachedLoadingWithError = < return transformLoadingDataWithError(data, transformRef.current) } return data as LoadingDataWithError - }, [loadable]) + }, [loadable, transformRef]) } // Convert to LoadingData for convenience, memoized. @@ -187,8 +187,7 @@ export const useCachedLoading = ( ): LoadingData => { const loadable = useCachedLoadable(recoilValue) - const onErrorRef = useRef(onError) - onErrorRef.current = onError + const onErrorRef = useUpdatingRef(onError) // Use deep compare to prevent memoize on every re-render if an object is // passed as the default value. @@ -197,6 +196,6 @@ export const useCachedLoading = ( return useMemo( () => loadableToLoadingData(loadable, memoizedDefaultValue, onErrorRef.current), - [loadable, memoizedDefaultValue] + [loadable, memoizedDefaultValue, onErrorRef] ) } diff --git a/packages/types/components/DaoCard.tsx b/packages/types/components/DaoCard.tsx index ba5e50be9..a518ff937 100644 --- a/packages/types/components/DaoCard.tsx +++ b/packages/types/components/DaoCard.tsx @@ -38,6 +38,15 @@ export type DaoCardProps = { * Whether or not the token loaded in lazy data is USD. Defaults to true. */ showingEstimatedUsdValue?: boolean + /** + * Whether or not to show the parent DAO if it exists. This is used primarily + * to hide the parent DAO until the app is mounted in the browser since + * rendering it on the server causes a hydration error for some horrible + * reason. I think it has something to do with the fact that you're not + * supposed to nest an a tag inside of another a tag, and maybe the Next.js + * server is sanitizing it or something. Anyways, rip. Defaults to true. + */ + showParentDao?: boolean onMouseOver?: () => void onMouseLeave?: () => void /** diff --git a/packages/types/components/EntityDisplay.tsx b/packages/types/components/EntityDisplay.tsx index 7056fb0fe..01ed4e111 100644 --- a/packages/types/components/EntityDisplay.tsx +++ b/packages/types/components/EntityDisplay.tsx @@ -17,6 +17,13 @@ export type Entity = { address: string name: string | null imageUrl: string + /** + * If loaded from a Polytone proxy, this will be set to the proxy. + */ + polytoneProxy?: { + chainId: string + address: string + } } & ( | { type: EntityType.Wallet @@ -28,11 +35,6 @@ export type Entity = { | { type: EntityType.Dao daoInfo: DaoInfo - // If loaded from a DAO's Polytone proxy, this will be set. - polytoneProxy?: { - chainId: string - address: string - } } | { type: EntityType.Cw1Whitelist diff --git a/packages/types/contracts/DaoCore.v2.ts b/packages/types/contracts/DaoCore.v2.ts index e7382eaa7..704756717 100644 --- a/packages/types/contracts/DaoCore.v2.ts +++ b/packages/types/contracts/DaoCore.v2.ts @@ -310,3 +310,8 @@ export interface VotingPowerAtHeightResponse { export type ProposalModuleWithInfo = ProposalModule & { info?: ContractVersionInfo } + +export type SubDaoWithChainId = SubDao & { + chainId: string +} +export type ListAllSubDaosResponse = SubDaoWithChainId[] diff --git a/packages/types/contracts/PolytoneVoice.ts b/packages/types/contracts/PolytoneVoice.ts new file mode 100644 index 000000000..f9128e046 --- /dev/null +++ b/packages/types/contracts/PolytoneVoice.ts @@ -0,0 +1,35 @@ +/** + * This file was automatically generated by @cosmwasm/ts-codegen@1.10.0. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run the @cosmwasm/ts-codegen generate command to regenerate this file. + */ + +export type Uint64 = string +export interface InstantiateMsg { + block_max_gas: Uint64 + contract_addr_len?: number | null + proxy_code_id: Uint64 +} +export type ExecuteMsg = { + rx: { + connection_id: string + counterparty_port: string + data: Binary + } +} +export type Binary = string +export type QueryMsg = + | 'block_max_gas' + | 'proxy_code_id' + | 'contract_addr_len' + | { + sender_info_for_proxy: { + proxy: string + } + } +export type Uint8 = number +export interface SenderInfo { + connection_id: string + remote_port: string + remote_sender: string +} diff --git a/packages/types/contracts/common.ts b/packages/types/contracts/common.ts index 94cef65ba..05da5dc0e 100644 --- a/packages/types/contracts/common.ts +++ b/packages/types/contracts/common.ts @@ -341,3 +341,7 @@ export type ActiveThreshold = percent: Decimal } } + +export type ActiveThresholdResponse = { + active_threshold?: ActiveThreshold | null +} diff --git a/packages/types/dao.ts b/packages/types/dao.ts index edb181457..950fef555 100644 --- a/packages/types/dao.ts +++ b/packages/types/dao.ts @@ -62,6 +62,7 @@ export type DaoInfo = { votingModuleAddress: string votingModuleContractName: string proposalModules: ProposalModule[] + admin: string name: string description: string imageUrl: string @@ -72,9 +73,7 @@ export type DaoInfo = { // Map chain ID to polytone proxy address. polytoneProxies: PolytoneProxies accounts: Account[] - parentDao: DaoParentInfo | null - admin: string } export type DaoParentInfo = { @@ -82,9 +81,9 @@ export type DaoParentInfo = { coreAddress: string coreVersion: ContractVersion name: string - imageUrl?: string | null - parentDao?: DaoParentInfo | null + imageUrl: string admin: string + parentDao: DaoParentInfo | null // Whether or not this parent has registered its child as a SubDAO. registeredSubDao: boolean diff --git a/packages/types/misc.ts b/packages/types/misc.ts index b80280722..3ac553e16 100644 --- a/packages/types/misc.ts +++ b/packages/types/misc.ts @@ -20,14 +20,15 @@ export type CachedLoadable = contents: Error } -// These are convenience types that are more useful in UI components. They force -// you to check if data is loading before TypeScript allows you to access the -// data, and they also allow you to check if the data is updating. It is hard to -// use Recoil's loadable types in Storybook stories (to mock components), and -// these types make it much easier. See them used in -// `packages/utils/conversion.ts` and -// `packages/stateless/hooks/useCachedLoadable.ts`. - +/** + * Convenience type that is easier to use in UI components. This serves to + * separate the component from the library used to load state/data, preventing + * us from having to use a specific library's types inside of our components. + * This makes it easier to migrate between different data layers and other + * libraries in the future, such as moving from Recoil to React Query. + * + * See this used in `packages/stateful/hooks/useQueryLoadingData.ts` + */ export type LoadingData = | { loading: true @@ -38,6 +39,15 @@ export type LoadingData = data: D } +/** + * Convenience type that is easier to use in UI components. This serves to + * separate the component from the library used to load state/data, preventing + * us from having to use a specific library's types inside of our components. + * This makes it easier to migrate between different data layers and other + * libraries in the future, such as moving from Recoil to React Query. + * + * See this used in `packages/stateful/hooks/useQueryLoadingDataWithError.ts` + */ export type LoadingDataWithError = | { loading: true diff --git a/packages/types/package.json b/packages/types/package.json index 448d88fce..14ec0dbeb 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -23,7 +23,8 @@ }, "devDependencies": { "@cosmology/telescope": "^1.4.12", - "@dao-dao/config": "2.4.0-rc.8" + "@dao-dao/config": "2.4.0-rc.8", + "@tanstack/react-query": "^5.40.0" }, "peerDependencies": { "next": "^12 || ^13", diff --git a/packages/types/proposal-module-adapter.ts b/packages/types/proposal-module-adapter.ts index e6582cf30..ea50ee5a4 100644 --- a/packages/types/proposal-module-adapter.ts +++ b/packages/types/proposal-module-adapter.ts @@ -1,4 +1,5 @@ import { Chain } from '@chain-registry/types' +import { QueryClient } from '@tanstack/react-query' import { CSSProperties, ComponentType, ReactNode } from 'react' import { FieldPath, FieldValues } from 'react-hook-form' import { RecoilValueReadOnly } from 'recoil' @@ -221,6 +222,7 @@ export type IProposalModuleCommonContext = { // Internal Adapter Types export type FetchPreProposeFunction = ( + queryClient: QueryClient, chainId: string, proposalModuleAddress: string, version: ContractVersion | null diff --git a/packages/utils/assets.ts b/packages/utils/assets.ts index 953b7e3f5..f0c3a084c 100644 --- a/packages/utils/assets.ts +++ b/packages/utils/assets.ts @@ -1,9 +1,9 @@ import { fromBech32 } from '@cosmjs/encoding' -import { assets } from 'chain-registry' import { GenericToken, TokenType } from '@dao-dao/types' import { getChainForChainId } from './chain' +import { assets } from './constants' import { concatAddressStartEnd } from './conversion' import { getFallbackImage } from './getFallbackImage' diff --git a/packages/utils/chain.ts b/packages/utils/chain.ts index 40a7e4d45..cc0c79785 100644 --- a/packages/utils/chain.ts +++ b/packages/utils/chain.ts @@ -3,7 +3,6 @@ import { Buffer } from 'buffer' import { AssetList, Chain, IBCInfo } from '@chain-registry/types' import { fromBech32, fromHex, toBech32 } from '@cosmjs/encoding' import { GasPrice } from '@cosmjs/stargate' -import { assets, chains } from 'chain-registry' import RIPEMD160 from 'ripemd160' import semverGte from 'semver/functions/gte' @@ -22,8 +21,7 @@ import { TokenType, Validator, } from '@dao-dao/types' -import { aminoTypes, cosmos, typesRegistry } from '@dao-dao/types/protobuf' -import { ModuleAccount } from '@dao-dao/types/protobuf/codegen/cosmos/auth/v1beta1/auth' +import { aminoTypes, typesRegistry } from '@dao-dao/types/protobuf' import { Validator as RpcValidator, bondStatusToJSON, @@ -35,68 +33,12 @@ import { CONFIGURED_CHAINS, MAINNET, SUPPORTED_CHAINS, + assets, + chains, ibc, } from './constants' import { getFallbackImage } from './getFallbackImage' -// BitSong Testnet -const bitSongTestnetChain: Chain = { - chain_name: 'bitsongtestnet', - status: 'live', - network_type: 'testnet', - pretty_name: 'BitSong Testnet', - chain_id: 'bobnet', - bech32_prefix: 'bitsong', - bech32_config: { - bech32PrefixAccAddr: 'bitsong', - bech32PrefixAccPub: 'bitsongpub', - bech32PrefixValAddr: 'bitsongvaloper', - bech32PrefixValPub: 'bitsongvaloperpub', - bech32PrefixConsAddr: 'bitsongvalcons', - bech32PrefixConsPub: 'bitsongvalconspub', - }, - slip44: 639, - logo_URIs: { - png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/bitsong/images/btsg.png', - }, - fees: { - fee_tokens: [ - { - denom: 'ubtsg', - fixed_min_gas_price: 0, - low_gas_price: 0, - average_gas_price: 0, - high_gas_price: 0, - }, - ], - }, - staking: { - staking_tokens: [ - { - denom: 'ubtsg', - }, - ], - }, - apis: { - rpc: [ - { - address: 'https://rpc-testnet.explorebitsong.com', - }, - ], - rest: [ - { - address: 'https://lcd-testnet.explorebitsong.com', - }, - ], - }, -} -chains.push(bitSongTestnetChain) -assets.push({ - chain_name: bitSongTestnetChain.chain_name, - // Copy assets from BitSong mainnet. - assets: assets.find((a) => a.chain_name === 'bitsong')?.assets ?? [], -}) - export const getRpcForChainId = ( chainId: string, // Offset will try a different RPC from the list of available RPCs. @@ -691,50 +633,6 @@ export const getSignerOptions = ({ chain_id, fees }: Chain) => { } } -// Check whether or not the address is a module account. -export const addressIsModule = async ( - client: Awaited< - ReturnType - >['cosmos'], - address: string, - // If defined, check that the module is this module. - moduleName?: string -): Promise => { - try { - const { account } = await client.auth.v1beta1.account({ - address, - }) - - if (!account) { - return false - } - - if (account.typeUrl === ModuleAccount.typeUrl) { - const moduleAccount = ModuleAccount.decode(account.value) - return !moduleName || moduleAccount.name === moduleName - - // If already decoded automatically. - } else if (account.$typeUrl === ModuleAccount.typeUrl) { - return ( - !moduleName || (account as unknown as ModuleAccount).name === moduleName - ) - } - } catch (err) { - if ( - err instanceof Error && - (err.message.includes('not found: key not found') || - err.message.includes('decoding bech32 failed')) - ) { - return false - } - - // Rethrow other errors. - throw err - } - - return false -} - /** * Get the DAO info object for a given chain ID. */ diff --git a/packages/utils/client.ts b/packages/utils/client.ts index b2c760029..29739b5bd 100644 --- a/packages/utils/client.ts +++ b/packages/utils/client.ts @@ -8,90 +8,220 @@ import { connectComet, } from '@cosmjs/tendermint-rpc' -type ChainClientRoutes = { - [rpcEndpoint: string]: T -} +import { + cosmos, + cosmwasm, + ibc, + juno, + neutron, + noble, + osmosis, +} from '@dao-dao/types/protobuf' + +import { getRpcForChainId } from './chain' +import { retry } from './network' -type HandleConnect = (rpcEndpoint: string) => Promise +type HandleConnect = (chainId: string) => Promise +type ChainClientRouterOptions = { + /** + * The connection handler that returns the client for a given chain ID. + */ + handleConnect: HandleConnect +} /* - * This is a workaround for `@cosmjs` clients to avoid connecting to the chain more than once. + * This is a client wrapper that preserves singletons of connected clients for + * many chains. * * @example + * ``` * export const stargateClientRouter = new ChainClientRouter({ - * handleConnect: (rpcEndpoint: string) => StargateClient.connect(rpcEndpoint), + * handleConnect: (chainId: string) => StargateClient.connect( + * getRpcForChainId(chainId) + * ), * }) * - * const client = await stargateClientRouter.connect(RPC_ENDPOINT); + * const client = await stargateClientRouter.connect(CHAIN_ID); * * const queryResponse = await client.queryContractSmart(...); - * */ + * ``` + */ class ChainClientRouter { private readonly handleConnect: HandleConnect - private instances: ChainClientRoutes = {} + private instances: Record = {} - constructor({ handleConnect }: { handleConnect: HandleConnect }) { + constructor({ handleConnect }: ChainClientRouterOptions) { this.handleConnect = handleConnect } /* - * Connect to the chain and return the client - * or return an existing instance of the client. - * */ - async connect(rpcEndpoint: string) { - if (!this.getClientInstance(rpcEndpoint)) { - const instance = await this.handleConnect(rpcEndpoint) - this.setClientInstance(rpcEndpoint, instance) + * Connect to the chain and return the client or return an existing instance + * of the client. + */ + async connect(chainId: string): Promise { + if (!this.instances[chainId]) { + const instance = await this.handleConnect(chainId) + this.instances[chainId] = instance } - return this.getClientInstance(rpcEndpoint) - } - - private getClientInstance(rpcEndpoint: string) { - return this.instances[rpcEndpoint] - } - - private setClientInstance(rpcEndpoint: string, client: T) { - this.instances[rpcEndpoint] = client + return this.instances[chainId] } } /* * Router for connecting to `CosmWasmClient`. - * */ + */ export const cosmWasmClientRouter = new ChainClientRouter({ - handleConnect: async (rpcEndpoint: string) => { - const httpClient = new HttpBatchClient(rpcEndpoint) - const tmClient = await ( - ( - await connectComet(rpcEndpoint) - ).constructor as - | typeof Tendermint34Client - | typeof Tendermint37Client - | typeof Comet38Client - ).create(httpClient) - - return await CosmWasmClient.create(tmClient) - }, + handleConnect: async (chainId: string) => + retry(10, async (attempt) => { + const rpc = getRpcForChainId(chainId, attempt - 1) + + const httpClient = new HttpBatchClient(rpc) + const tmClient = await ( + ( + await connectComet(rpc) + ).constructor as + | typeof Tendermint34Client + | typeof Tendermint37Client + | typeof Comet38Client + ).create(httpClient) + + return await CosmWasmClient.create(tmClient) + }), }) /* * Router for connecting to `StargateClient`. - * */ + */ export const stargateClientRouter = new ChainClientRouter({ - handleConnect: async (rpcEndpoint: string) => { - const httpClient = new HttpBatchClient(rpcEndpoint) - const tmClient = await ( - ( - await connectComet(rpcEndpoint) - ).constructor as - | typeof Tendermint34Client - | typeof Tendermint37Client - | typeof Comet38Client - ).create(httpClient) - - return await StargateClient.create(tmClient, {}) - }, + handleConnect: async (chainId: string) => + retry(10, async (attempt) => { + const rpc = getRpcForChainId(chainId, attempt - 1) + + const httpClient = new HttpBatchClient(rpc) + const tmClient = await ( + ( + await connectComet(rpc) + ).constructor as + | typeof Tendermint34Client + | typeof Tendermint37Client + | typeof Comet38Client + ).create(httpClient) + + return await StargateClient.create(tmClient) + }), +}) + +/* + * Router for connecting to an RPC client with Cosmos protobufs. + */ +export const cosmosProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await cosmos.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).cosmos + ), +}) + +/* + * Router for connecting to an RPC client with IBC protobufs. + */ +export const ibcProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await ibc.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).ibc + ), +}) + +/* + * Router for connecting to an RPC client with CosmWasm protobufs. + */ +export const cosmwasmProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await cosmwasm.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).cosmwasm + ), +}) + +/* + * Router for connecting to an RPC client with Osmosis protobufs. + */ +export const osmosisProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await osmosis.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).osmosis + ), +}) + +/* + * Router for connecting to an RPC client with Noble protobufs. + */ +export const nobleProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await noble.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).noble + ), +}) + +/* + * Router for connecting to an RPC client with Neutron protobufs. + */ +export const neutronProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await neutron.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).neutron + ), +}) + +/* + * Router for connecting to an RPC client with Juno protobufs. + */ +export const junoProtoRpcClientRouter = new ChainClientRouter({ + handleConnect: async (chainId: string) => + retry( + 10, + async (attempt) => + ( + await juno.ClientFactory.createRPCQueryClient({ + rpcEndpoint: getRpcForChainId(chainId, attempt - 1), + }) + ).juno + ), }) /** diff --git a/packages/utils/constants/chains.ts b/packages/utils/constants/chains.ts index 3e405dec4..2f7de3bc6 100644 --- a/packages/utils/constants/chains.ts +++ b/packages/utils/constants/chains.ts @@ -1,5 +1,9 @@ -import { IBCInfo } from '@chain-registry/types' -import { ibc as chainRegistryIbc, chains } from 'chain-registry' +import { Chain, IBCInfo } from '@chain-registry/types' +import { + assets as chainRegistryAssets, + chains as chainRegistryChains, + ibc as chainRegistryIbc, +} from 'chain-registry' import { BaseChainConfig, @@ -11,6 +15,75 @@ import { import { NEUTRON_GOVERNANCE_DAO } from './other' +//! ----- Modified chain-registry ----- +let chains = [...chainRegistryChains] +const assets = [...chainRegistryAssets] + +// BitSong Testnet +const bitSongTestnetChain: Chain = { + chain_name: 'bitsongtestnet', + status: 'live', + network_type: 'testnet', + pretty_name: 'BitSong Testnet', + chain_id: 'bobnet', + bech32_prefix: 'bitsong', + bech32_config: { + bech32PrefixAccAddr: 'bitsong', + bech32PrefixAccPub: 'bitsongpub', + bech32PrefixValAddr: 'bitsongvaloper', + bech32PrefixValPub: 'bitsongvaloperpub', + bech32PrefixConsAddr: 'bitsongvalcons', + bech32PrefixConsPub: 'bitsongvalconspub', + }, + slip44: 639, + logo_URIs: { + png: 'https://raw.githubusercontent.com/cosmos/chain-registry/master/bitsong/images/btsg.png', + }, + fees: { + fee_tokens: [ + { + denom: 'ubtsg', + fixed_min_gas_price: 0, + low_gas_price: 0, + average_gas_price: 0, + high_gas_price: 0, + }, + ], + }, + staking: { + staking_tokens: [ + { + denom: 'ubtsg', + }, + ], + }, + apis: { + rpc: [ + { + address: 'https://rpc-testnet.explorebitsong.com', + }, + ], + rest: [ + { + address: 'https://lcd-testnet.explorebitsong.com', + }, + ], + }, +} +chains.push(bitSongTestnetChain) +assets.push({ + chain_name: bitSongTestnetChain.chain_name, + // Copy assets from BitSong mainnet. + assets: assets.find((a) => a.chain_name === 'bitsong')?.assets ?? [], +}) + +// Remove thorchain and althea since they spam the console. +const chainsToRemove = ['thorchain', 'althea'] +chains = chains.filter((chain) => !chainsToRemove.includes(chain.chain_name)) + +export { chains, assets } +//! ----- Modified chain-registry ----- + export const ibc: IBCInfo[] = [ ...chainRegistryIbc, // Oraichain <-> Cosmos Hub diff --git a/packages/utils/contracts.ts b/packages/utils/contracts.ts index e9f82db6a..6324246a0 100644 --- a/packages/utils/contracts.ts +++ b/packages/utils/contracts.ts @@ -9,12 +9,10 @@ import { encodeJsonToBase64 } from './messages' const CONTRACT_VERSIONS = Object.values(ContractVersion) -// If version is defined, returns it. Otherwise, returns `undefined`. -// Essentially just filters version by its presence in the `ContractVersion` -// enum. -export const parseContractVersion = ( - version: string -): ContractVersion | undefined => CONTRACT_VERSIONS.find((v) => v === version) +// If version is defined, returns it. Otherwise, returns +// ContractVersion.Unknown. +export const parseContractVersion = (version: string): ContractVersion => + CONTRACT_VERSIONS.find((v) => v === version) || ContractVersion.Unknown export const indexToProposalModulePrefix = (index: number) => { index += 1 diff --git a/packages/utils/conversion.ts b/packages/utils/conversion.ts index f6011b868..adc58b7a9 100644 --- a/packages/utils/conversion.ts +++ b/packages/utils/conversion.ts @@ -1,4 +1,5 @@ import { fromBech32, toBech32, toHex } from '@cosmjs/encoding' +import { UseQueryResult } from '@tanstack/react-query' import { TFunction } from 'next-i18next' import { Loadable } from 'recoil' @@ -243,6 +244,56 @@ export const combineLoadingDataWithErrors = ( data: loadables.flatMap((l) => (l.loading || l.errored ? [] : l.data)), } +/** + * Combine react-query results into LoadingData list. + */ +export const makeCombineQueryResultsIntoLoadingData = + ({ + firstLoad = 'all', + transform = (results: T[]) => results as R, + }: { + /** + * Whether or not to show loading until all of the results are loaded, at + * least one result is loaded, or none of the results are loaded. If 'one', + * will show not loading (just updating) once the first result is loaded. If + * 'none', will never show loading. + * + * Defaults to 'all'. + */ + firstLoad?: 'all' | 'one' | 'none' + /** + * Optional transformation function that acts on combined list of data. + */ + transform?: (results: T[]) => R + }) => + (results: UseQueryResult[]): LoadingData => { + const isLoading = + firstLoad === 'all' + ? results.some((r) => r.isPending) + : firstLoad === 'one' + ? results.every((r) => r.isPending) + : false + + if (isLoading) { + return { + loading: true, + } + } else { + return { + loading: false, + updating: results.some((r) => r.isPending || r.isFetching), + // Cast data to T if not pending since it's possible that data has + // successfully loaded and returned undefined. isPending will be true if + // data is not yet loaded. + data: transform( + results.flatMap((r) => + r.isPending || r.isError ? [] : [r.data as T] + ) + ), + } + } + } + // Convert Recoil loadable into our generic data loader with error type. See the // comment above the LoadingData type for more details. export const loadableToLoadingDataWithError = ( diff --git a/packages/utils/nft.ts b/packages/utils/nft.ts index 19969266c..1ae46d7d9 100644 --- a/packages/utils/nft.ts +++ b/packages/utils/nft.ts @@ -73,6 +73,17 @@ export const getNftKey = ( .filter(Boolean) .join(':') +export const imageUrlFromStargazeIndexerNft = ( + token: StargazeNft +): string | undefined => + // The Stargaze API resizes animated images (gifs) into `video/mp4` mimetype, + // which cannot display in an `img` tag. If this is a gif, use the original + // media URL instead of the resized one. + (token.media?.type !== StargazeNftMediaType.AnimatedImage && + token.media?.visualAssets?.lg?.url) || + token.media?.url || + undefined + export const nftCardInfoFromStargazeIndexerNft = ( chainId: string, token: StargazeNft, @@ -92,14 +103,7 @@ export const nftCardInfoFromStargazeIndexerNft = ( href: `${STARGAZE_URL_BASE}/media/${token.collection.contractAddress}/${token.tokenId}`, name: 'Stargaze', }, - imageUrl: - // The Stargaze API resizes animated images (gifs) into `video/mp4` - // mimetype, which cannot display in an `img` tag. If this is a gif, use the - // original media URL instead of the resized one. - (token.media?.type !== StargazeNftMediaType.AnimatedImage && - token.media?.visualAssets?.lg?.url) || - token.media?.url || - undefined, + imageUrl: imageUrlFromStargazeIndexerNft(token), name: token.name || token.tokenId || 'Unknown NFT', description: token.description || undefined, highestOffer: offerToken diff --git a/packages/utils/package.json b/packages/utils/package.json index f0cff0466..4a32c6fb8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -38,6 +38,7 @@ "@chain-registry/types": "^0.41.3", "@cosmjs/proto-signing": "^0.32.3", "@dao-dao/config": "2.4.0-rc.8", + "@tanstack/react-query": "^5.40.0", "cosmjs-types": "^0.9.0", "jest": "^29.1.1", "next": "^13.3.0", diff --git a/packages/utils/profile.ts b/packages/utils/profile.ts index 20d279486..b5f3f8f53 100644 --- a/packages/utils/profile.ts +++ b/packages/utils/profile.ts @@ -2,20 +2,20 @@ import { PfpkProfile, UnifiedProfile } from '@dao-dao/types' import { getFallbackImage } from './getFallbackImage' -export const EMPTY_PFPK_PROFILE: PfpkProfile = { +export const makeEmptyPfpkProfile = (): PfpkProfile => ({ uuid: null, // Disallows editing if we don't have correct nonce from server. nonce: -1, name: null, nft: null, chains: {}, -} +}) export const makeEmptyUnifiedProfile = ( chainId: string, address: string ): UnifiedProfile => ({ - ...EMPTY_PFPK_PROFILE, + ...makeEmptyPfpkProfile(), source: { chainId, address, diff --git a/yarn.lock b/yarn.lock index 7d8ec1575..6baedf888 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7746,6 +7746,18 @@ lodash.isplainobject "^4.0.6" lodash.merge "^4.6.2" +"@tanstack/query-core@5.40.0": + version "5.40.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.40.0.tgz#c74ae8303752ed4b5a0ab848ec71a0e6e8179f83" + integrity sha512-eD8K8jsOIq0Z5u/QbvOmfvKKE/XC39jA7yv4hgpl/1SRiU+J8QCIwgM/mEHuunQsL87dcvnHqSVLmf9pD4CiaA== + +"@tanstack/react-query@^5.40.0": + version "5.40.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.40.0.tgz#654afa2d9ab328c22be7e1f025ec9b6267c6baa9" + integrity sha512-iv/W0Axc4aXhFzkrByToE1JQqayxTPNotCoSCnarR/A1vDIHaoKpg7FTIfP3Ev2mbKn1yrxq0ZKYUdLEJxs6Tg== + dependencies: + "@tanstack/query-core" "5.40.0" + "@terra-money/feather.js@^1.0.8": version "1.2.1" resolved "https://registry.yarnpkg.com/@terra-money/feather.js/-/feather.js-1.2.1.tgz#e89f615aa3628a8e87720c161cecc4ae402a057f"