diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f56f12f..b73bcc4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#1033](https://github.com/alleslabs/celatone-frontend/pull/1033) Add Sequencer for NFT details - [#1015](https://github.com/alleslabs/celatone-frontend/pull/1015) New network selector - [#1032](https://github.com/alleslabs/celatone-frontend/pull/1032) Search collection address on both full and sequencer tier - [#1024](https://github.com/alleslabs/celatone-frontend/pull/1024) Add Sequencer for account detail NFTs diff --git a/src/lib/app-provider/env.ts b/src/lib/app-provider/env.ts index b5f95ece4..5671c9eb4 100644 --- a/src/lib/app-provider/env.ts +++ b/src/lib/app-provider/env.ts @@ -156,6 +156,7 @@ export enum CELATONE_QUERY_KEYS { NFT_TOKEN_MINT_INFO = "CELATONE_QUERY_NFT_TOKEN_MINT_INFO", NFT_METADATA = "CELATONE_QUERY_NFT_METADATA", NFT_TRANSACTIONS = "CELATONE_QUERY_NFT_TRANSACTIONS", + NFT_TRANSACTIONS_SEQUENCER = "CELATONE_QUERY_NFT_TRANSACTIONS_SEQUENCER", NFT_TRANSACTIONS_COUNT = "CELATONE_QUERY_NFT_TRANSACTIONS_COUNT", NFT_MUTATE_EVENTS = "CELATONE_QUERY_NFT_MUTATE_EVENTS", NFT_MUTATE_EVENTS_COUNT = "CELATONE_QUERY_NFT_MUTATE_EVENTS_COUNT", diff --git a/src/lib/components/nft/NftCard.tsx b/src/lib/components/nft/NftCard.tsx index cc7c1fc49..aceec9de6 100644 --- a/src/lib/components/nft/NftCard.tsx +++ b/src/lib/components/nft/NftCard.tsx @@ -4,14 +4,14 @@ import { AppLink } from "../AppLink"; import { AmpEvent, track } from "lib/amplitude"; import { NFT_IMAGE_PLACEHOLDER } from "lib/data"; import { useMetadata } from "lib/services/nft"; -import type { HexAddr32, Option } from "lib/types"; +import type { HexAddr32, Nullable } from "lib/types"; interface NftCardProps { uri: string; tokenId: string; - collectionName: string; + collectionName: Nullable; collectionAddress: HexAddr32; - nftAddress: Option; + nftAddress: Nullable; showCollection?: boolean; } diff --git a/src/lib/pages/account-details/components/nfts/FilterItem.tsx b/src/lib/pages/account-details/components/nfts/FilterItem.tsx index cc51f6524..d2a519ce6 100644 --- a/src/lib/pages/account-details/components/nfts/FilterItem.tsx +++ b/src/lib/pages/account-details/components/nfts/FilterItem.tsx @@ -4,9 +4,10 @@ import { AmpEvent, track } from "lib/amplitude"; import { CustomIcon } from "lib/components/icon"; import { NFT_IMAGE_PLACEHOLDER } from "lib/data"; import { useMetadata } from "lib/services/nft"; +import type { Nullable } from "lib/types"; interface FilterItemProps { - collectionName: string; + collectionName: Nullable; count: number; onClick: () => void; uri?: string; diff --git a/src/lib/pages/nft-details/components/Title.tsx b/src/lib/pages/nft-details/components/Title.tsx index 470f9d0f3..5cab712dd 100644 --- a/src/lib/pages/nft-details/components/Title.tsx +++ b/src/lib/pages/nft-details/components/Title.tsx @@ -11,7 +11,7 @@ interface TitleProps { nftAddress: HexAddr32; displayCollectionName: string; tokenId: string; - isBurned?: boolean; + isBurned: boolean; } export const Title = ({ diff --git a/src/lib/pages/nft-details/components/tables/txs/index.tsx b/src/lib/pages/nft-details/components/tables/txs/TxsFull.tsx similarity index 93% rename from src/lib/pages/nft-details/components/tables/txs/index.tsx rename to src/lib/pages/nft-details/components/tables/txs/TxsFull.tsx index f3ee676f8..15b20d2aa 100644 --- a/src/lib/pages/nft-details/components/tables/txs/index.tsx +++ b/src/lib/pages/nft-details/components/tables/txs/TxsFull.tsx @@ -8,12 +8,12 @@ import type { HexAddr32 } from "lib/types"; import { TxsTable } from "./TxsTable"; -interface TxsProps { +interface TxsFullProps { nftAddress: HexAddr32; totalData: number; } -export const Txs = ({ nftAddress, totalData }: TxsProps) => { +export const TxsFull = ({ nftAddress, totalData }: TxsFullProps) => { const { pagesQuantity, currentPage, diff --git a/src/lib/pages/nft-details/components/tables/txs/TxsSequencer.tsx b/src/lib/pages/nft-details/components/tables/txs/TxsSequencer.tsx new file mode 100644 index 000000000..5e2df7ace --- /dev/null +++ b/src/lib/pages/nft-details/components/tables/txs/TxsSequencer.tsx @@ -0,0 +1,24 @@ +import { EmptyState } from "lib/components/state"; +import { useNftTransactionsSequencer } from "lib/services/nft"; +import type { HexAddr32 } from "lib/types"; + +import { TxsTable } from "./TxsTable"; + +interface TxsSequencerProps { + nftAddress: HexAddr32; +} + +export const TxsSequencer = ({ nftAddress }: TxsSequencerProps) => { + const { data: transactions, isLoading } = + useNftTransactionsSequencer(nftAddress); + + return ( + + } + /> + ); +}; diff --git a/src/lib/pages/nft-details/components/tables/txs/index.ts b/src/lib/pages/nft-details/components/tables/txs/index.ts new file mode 100644 index 000000000..d153f23f3 --- /dev/null +++ b/src/lib/pages/nft-details/components/tables/txs/index.ts @@ -0,0 +1,2 @@ +export * from "./TxsFull"; +export * from "./TxsSequencer"; diff --git a/src/lib/pages/nft-details/index.tsx b/src/lib/pages/nft-details/index.tsx index b831f65e2..b2e2a6d0c 100644 --- a/src/lib/pages/nft-details/index.tsx +++ b/src/lib/pages/nft-details/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { Divider, Flex, @@ -22,6 +23,7 @@ import { Loading } from "lib/components/Loading"; import PageContainer from "lib/components/PageContainer"; import { CelatoneSeo } from "lib/components/Seo"; import { ErrorFetching, InvalidState } from "lib/components/state"; +import { TierSwitcher } from "lib/components/TierSwitcher"; import { Tooltip } from "lib/components/Tooltip"; import { UserDocsLink } from "lib/components/UserDocsLink"; import { NFT_IMAGE_PLACEHOLDER } from "lib/data"; @@ -30,6 +32,7 @@ import { useNftByNftAddress, useNftMutateEventsCount, useNftTransactionsCount, + useNftTransactionsSequencer, } from "lib/services/nft"; import { useCollectionByCollectionAddress } from "lib/services/nft-collection"; @@ -41,7 +44,8 @@ import { NftInfoItem, NftMutateEvents, Title, - Txs, + TxsFull, + TxsSequencer, ViewResourceButton, } from "./components"; import type { NftDetailQueryParams } from "./types"; @@ -54,6 +58,7 @@ const NftDetailsBody = ({ nftAddress, }: NftDetailQueryParams) => { const isMobile = useMobile(); + const { isFullTier, isSequencerTier } = useTierConfig(); const { data: collection, isLoading: isCollectionLoading } = useCollectionByCollectionAddress(collectionAddress); @@ -61,8 +66,18 @@ const NftDetailsBody = ({ collectionAddress, nftAddress ); - const { data: txCount = 0 } = useNftTransactionsCount(nftAddress); - const { data: mutateEventsCount = 0 } = useNftMutateEventsCount(nftAddress); + + const { data: txCount = 0 } = useNftTransactionsCount(nftAddress, isFullTier); + const { data: transactions } = useNftTransactionsSequencer( + nftAddress, + isSequencerTier + ); + const totalTxs = isFullTier ? txCount : transactions?.length ?? 0; + + const { data: mutateEventsCount = 0 } = useNftMutateEventsCount( + nftAddress, + isFullTier + ); const { data: metadata } = useMetadata(nft?.data?.uri ?? ""); if (isCollectionLoading || isNftLoading) return ; @@ -247,24 +262,31 @@ const NftDetailsBody = ({ borderColor="gray.700" overflowX="scroll" > - Transactions - - Mutate Events - + Transactions + {isFullTier && ( + + Mutate Events + + )} - - - - } + sequencer={} /> + {isFullTier && ( + + + + )} { - useTierConfig({ minTier: "full" }); + useTierConfig({ minTier: "sequencer" }); const router = useRouter(); const validated = zNftDetailQueryParams.safeParse(router.query); diff --git a/src/lib/services/move/module/api.ts b/src/lib/services/move/module/api.ts index 2c06efb13..6e563b35c 100644 --- a/src/lib/services/move/module/api.ts +++ b/src/lib/services/move/module/api.ts @@ -13,6 +13,7 @@ import { zModuleTableCountsResponse, zModuleTxsResponse, zModuleVerificationInternal, + zMoveViewJsonResponse, } from "lib/services/types"; import type { AbiFormData, @@ -180,3 +181,21 @@ export const getModuleRelatedProposals = async ( } ) .then(({ data }) => parseWithError(zModuleRelatedProposalsResponse, data)); + +export const getMoveViewJson = async ( + endpoint: string, + vmAddress: HexAddr, + moduleName: string, + functionName: string, + typeArgs: string[], + args: string[] +) => + axios + .post(`${endpoint}/initia/move/v1/view/json`, { + address: vmAddress, + module_name: moduleName, + function_name: functionName, + type_args: typeArgs, + args, + }) + .then(({ data }) => parseWithError(zMoveViewJsonResponse, data)); diff --git a/src/lib/services/nft-collection/index.ts b/src/lib/services/nft-collection/index.ts index 55031ce4f..72f9bd36a 100644 --- a/src/lib/services/nft-collection/index.ts +++ b/src/lib/services/nft-collection/index.ts @@ -64,6 +64,7 @@ export const useCollectionByCollectionAddress = ( const { chainConfig } = useCelatoneApp(); const { tier } = useTierConfig(); const lcdEndpoint = useLcdEndpoint(); + return useQuery( [ CELATONE_QUERY_KEYS.NFT_COLLECTION_BY_COLLECTION_ADDRESS, @@ -76,18 +77,17 @@ export const useCollectionByCollectionAddress = ( handleQueryByTier({ tier, threshold: "sequencer", - querySequencer: () => - getCollectionByCollectionAddressSequencer( - lcdEndpoint, - collectionAddress - ), queryFull: () => getCollectionByCollectionAddress( chainConfig.indexer, collectionAddress ), + querySequencer: () => + getCollectionByCollectionAddressSequencer( + lcdEndpoint, + collectionAddress + ), }), - { retry: 1, refetchOnWindowFocus: false, diff --git a/src/lib/services/nft/index.ts b/src/lib/services/nft/index.ts index fcf3fdb91..efcd29fa8 100644 --- a/src/lib/services/nft/index.ts +++ b/src/lib/services/nft/index.ts @@ -4,16 +4,18 @@ import { useQuery } from "@tanstack/react-query"; import type { Metadata, Nft, - NftByNftAddressResponse, NftMintInfo, NftsByAccountResponse, NftTransactions, } from "../types"; +import { handleQueryByTier } from "../utils"; import { CELATONE_QUERY_KEYS, useCelatoneApp, + useCurrentChain, useLcdEndpoint, useNftConfig, + useTierConfig, } from "lib/app-provider"; import type { HexAddr, HexAddr32, MutateEvent } from "lib/types"; @@ -29,7 +31,12 @@ import { getNftTransactions, getNftTransactionsCount, } from "./gql"; -import { getNftsByAccountSequencer } from "./sequencer"; +import { + getNftByNftAddressSequencer, + getNftMintInfoSequencer, + getNftsByAccountSequencer, + getNftTransactionsSequencer, +} from "./sequencer"; export const useNfts = ( collectionAddress: HexAddr32, @@ -62,16 +69,31 @@ export const useNftByNftAddress = ( nftAddress: HexAddr32 ) => { const { chainConfig } = useCelatoneApp(); + const { tier } = useTierConfig(); + const lcdEndpoint = useLcdEndpoint(); - return useQuery( + return useQuery( [ CELATONE_QUERY_KEYS.NFT_BY_NFT_ADDRESS, chainConfig.indexer, + lcdEndpoint, + tier, collectionAddress, nftAddress, ], async () => - getNftByNftAddress(chainConfig.indexer, collectionAddress, nftAddress), + handleQueryByTier({ + tier, + threshold: "sequencer", + queryFull: () => + getNftByNftAddress( + chainConfig.indexer, + collectionAddress, + nftAddress + ), + querySequencer: () => + getNftByNftAddressSequencer(lcdEndpoint, nftAddress), + }), { retry: 1, refetchOnWindowFocus: false, @@ -81,9 +103,29 @@ export const useNftByNftAddress = ( export const useNftMintInfo = (nftAddress: HexAddr32) => { const { chainConfig } = useCelatoneApp(); + const { + chain: { bech32_prefix: prefix }, + } = useCurrentChain(); + const { tier } = useTierConfig(); + const lcdEndpoint = useLcdEndpoint(); + return useQuery( - [CELATONE_QUERY_KEYS.NFT_TOKEN_MINT_INFO, chainConfig.indexer, nftAddress], - async () => getNftMintInfo(chainConfig.indexer, nftAddress), + [ + CELATONE_QUERY_KEYS.NFT_TOKEN_MINT_INFO, + chainConfig.indexer, + lcdEndpoint, + tier, + nftAddress, + prefix, + ], + async () => + handleQueryByTier({ + tier, + threshold: "sequencer", + queryFull: () => getNftMintInfo(chainConfig.indexer, nftAddress), + querySequencer: () => + getNftMintInfoSequencer(lcdEndpoint, prefix, nftAddress), + }), { retry: 1, refetchOnWindowFocus: false, @@ -109,6 +151,7 @@ export const useNftTransactions = ( nftAddress: HexAddr32 ) => { const { chainConfig } = useCelatoneApp(); + return useQuery( [ CELATONE_QUERY_KEYS.NFT_TRANSACTIONS, @@ -126,7 +169,32 @@ export const useNftTransactions = ( ); }; -export const useNftTransactionsCount = (nftAddress: HexAddr32) => { +export const useNftTransactionsSequencer = ( + nftAddress: HexAddr32, + enabled = true +) => { + const lcdEndpoint = useLcdEndpoint(); + + return useQuery( + [ + CELATONE_QUERY_KEYS.NFT_TRANSACTIONS_SEQUENCER, + lcdEndpoint, + nftAddress, + enabled, + ], + async () => getNftTransactionsSequencer(lcdEndpoint, nftAddress), + { + retry: 1, + refetchOnWindowFocus: false, + enabled, + } + ); +}; + +export const useNftTransactionsCount = ( + nftAddress: HexAddr32, + enabled = true +) => { const { chainConfig } = useCelatoneApp(); return useQuery( [ @@ -138,6 +206,7 @@ export const useNftTransactionsCount = (nftAddress: HexAddr32) => { { retry: 1, refetchOnWindowFocus: false, + enabled, } ); }; @@ -165,18 +234,23 @@ export const useNftMutateEvents = ( ); }; -export const useNftMutateEventsCount = (nftAddress: HexAddr32) => { +export const useNftMutateEventsCount = ( + nftAddress: HexAddr32, + enabled = true +) => { const { chainConfig } = useCelatoneApp(); return useQuery( [ CELATONE_QUERY_KEYS.NFT_MUTATE_EVENTS_COUNT, chainConfig.indexer, nftAddress, + enabled, ], async () => getNftMutateEventsCount(chainConfig.indexer, nftAddress), { retry: 1, refetchOnWindowFocus: false, + enabled, } ); }; diff --git a/src/lib/services/nft/sequencer.ts b/src/lib/services/nft/sequencer.ts index 74119742a..ceaf42d28 100644 --- a/src/lib/services/nft/sequencer.ts +++ b/src/lib/services/nft/sequencer.ts @@ -1,9 +1,15 @@ import axios from "axios"; -import type { Nft } from "../types"; -import { zNftsByAccountResponseSequencer } from "../types"; +import { getMoveViewJson } from "../move/module/api"; +import { getTxsByAccountAddressSequencer } from "../tx/sequencer"; +import type { Nft, NftMintInfo, NftTransactions } from "../types"; +import { zNftInfoSequencer, zNftsByAccountResponseSequencer } from "../types"; +import { zHexAddr } from "lib/types"; import type { HexAddr, HexAddr32, Nullable } from "lib/types"; -import { parseWithError } from "lib/utils"; +import { + convertAccountPubkeyToAccountAddress, + parseWithError, +} from "lib/utils"; export const getNftsByAccountSequencer = async ( endpoint: string, @@ -40,3 +46,105 @@ export const getNftsByAccountSequencer = async ( total: nfts.length, }; }; + +const getNftHolder = async (endpoint: string, nftAddress: HexAddr32) => + getMoveViewJson( + endpoint, + "0x1" as HexAddr, + "object", + "owner", + ["0x1::nft::Nft"], + [`"${nftAddress}"`] + ).then((data) => parseWithError(zHexAddr, data)); + +const getNftInfo = async (endpoint: string, nftAddress: HexAddr32) => + getMoveViewJson( + endpoint, + "0x1" as HexAddr, + "nft", + "nft_info", + [], + [`"${nftAddress}"`] + ).then((data) => parseWithError(zNftInfoSequencer, data)); + +export const getNftByNftAddressSequencer = async ( + endpoint: string, + nftAddress: HexAddr32 +) => + Promise.all([ + getNftHolder(endpoint, nftAddress), + getNftInfo(endpoint, nftAddress), + ]).then<{ data: Nft }>(([holder, info]) => ({ + data: { + uri: info.uri, + tokenId: info.tokenId, + description: info.description, + isBurned: false, + ownerAddress: holder, + nftAddress, + collectionAddress: info.collection, + collectionName: null, + }, + })); + +export const getNftMintInfoSequencer = async ( + endpoint: string, + prefix: string, + nftAddress: HexAddr32 +): Promise => { + const txsByAccountAddress = await getTxsByAccountAddressSequencer( + endpoint, + nftAddress, + undefined, + 1, + false + ); + + if (!txsByAccountAddress.items.length) + throw new Error("No mint transaction found"); + + const tx = txsByAccountAddress.items[0]; + + const sender = convertAccountPubkeyToAccountAddress(tx.signerPubkey, prefix); + + return { + minter: sender, + txhash: tx.hash, + height: tx.height, + timestamp: tx.created, + }; +}; + +export const getNftTransactionsSequencer = async ( + endpoint: string, + nftAddress: HexAddr32 +) => { + const txsByAccountAddress = await getTxsByAccountAddressSequencer( + endpoint, + nftAddress, + undefined, + undefined + ); + + const nftsTxs: NftTransactions[] = []; + + txsByAccountAddress.items.forEach((tx) => { + const { events, hash, created } = tx; + + events?.reverse()?.forEach((event) => { + if (!event.attributes[0].value.includes("0x1::object::")) return; + + const eventValue = event.attributes[0].value.split("::")[2]; + + nftsTxs.push({ + txhash: hash, + timestamp: created, + isNftBurn: false, + isNftMint: eventValue === "CreateEvent", + isNftTransfer: eventValue === "TransferEvent", + }); + }); + }); + + return nftsTxs; +}; diff --git a/src/lib/services/tx/sequencer.ts b/src/lib/services/tx/sequencer.ts index d77fef77c..6ab2cf15b 100644 --- a/src/lib/services/tx/sequencer.ts +++ b/src/lib/services/tx/sequencer.ts @@ -5,7 +5,7 @@ import { zTxsByHashResponseSequencer, zTxsResponseSequencer, } from "../types"; -import type { BechAddr20, Option } from "lib/types"; +import type { Addr, Option } from "lib/types"; import { parseWithError } from "lib/utils"; export const getTxsSequencer = async ( @@ -25,15 +25,16 @@ export const getTxsSequencer = async ( export const getTxsByAccountAddressSequencer = async ( endpoint: string, - address: BechAddr20, + address: Addr, paginationKey: Option, - limit: number + limit: Option, + reverse = true ) => axios .get(`${endpoint}/indexer/tx/v1/txs/by_account/${encodeURI(address)}`, { params: { "pagination.limit": limit, - "pagination.reverse": true, + "pagination.reverse": reverse, "pagination.key": paginationKey, }, }) diff --git a/src/lib/services/types/move/module.ts b/src/lib/services/types/move/module.ts index 611fdcad6..1bccb6259 100644 --- a/src/lib/services/types/move/module.ts +++ b/src/lib/services/types/move/module.ts @@ -153,3 +153,9 @@ export interface ModuleInitialPublishInfo { initProposalId: Option; initProposalTitle: Option; } + +export const zMoveViewJsonResponse = z + .object({ + data: z.string(), + }) + .transform((val) => JSON.parse(val.data)); diff --git a/src/lib/services/types/nft-collection.ts b/src/lib/services/types/nft-collection.ts index 74ee93318..764c272ca 100644 --- a/src/lib/services/types/nft-collection.ts +++ b/src/lib/services/types/nft-collection.ts @@ -78,11 +78,13 @@ export const zCollectionByCollectionAddressResponse = z.object({ description: val.description, name: val.name, uri: val.uri, - createdHeight: - val.vmAddressByCreator.collectionsByCreator[0].block_height, - creatorAddress: - val.vmAddressByCreator.collectionsByCreator[0].vmAddressByCreator - .vm_address, + createdHeight: val.vmAddressByCreator.collectionsByCreator.length + ? val.vmAddressByCreator.collectionsByCreator[0].block_height + : null, + creatorAddress: val.vmAddressByCreator.collectionsByCreator.length + ? val.vmAddressByCreator.collectionsByCreator[0].vmAddressByCreator + .vm_address + : null, })) .optional(), }); @@ -314,15 +316,12 @@ export const zCollectionByCollectionAddressResponseSequencer = z .object({ collection: zCollectionResponseSequencer, }) - .transform((val) => { - const { collection } = val.collection; - return { - data: { - description: collection.description, - name: collection.name, - uri: collection.uri, - createdHeight: 0, // TODO: make it nullable - creatorAddress: collection.creator, - }, - }; - }); + .transform((val) => ({ + data: { + description: val.collection.collection.description, + name: val.collection.collection.name, + uri: val.collection.collection.uri, + createdHeight: null, + creatorAddress: val.collection.collection.creator, + }, + })); diff --git a/src/lib/services/types/nft.ts b/src/lib/services/types/nft.ts index d741fd2a3..54b94064e 100644 --- a/src/lib/services/types/nft.ts +++ b/src/lib/services/types/nft.ts @@ -21,7 +21,7 @@ export const zNft = z id: zHexAddr32, collection: zHexAddr32, collectionByCollection: z.object({ - name: z.string(), + name: z.string().nullable(), }), }) .transform((val) => ({ @@ -192,3 +192,12 @@ export const zNftsByAccountResponseSequencer = z nfts: val.tokens, pagination: val.pagination, })); + +export const zNftInfoSequencer = z + .object({ + collection: zHexAddr32, + description: z.string(), + token_id: z.string(), + uri: z.string(), + }) + .transform(snakeToCamel);