diff --git a/package-lock.json b/package-lock.json index 65a3e5672..843b00d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "jotai": "^2.7.2", "jotai-effect": "^0.6.0", "lucide-react": "^0.356.0", + "multiformats": "^13.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", @@ -7214,6 +7215,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/multiformats": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.1.0.tgz", + "integrity": "sha512-HzdtdBwxsIkzpeXzhQ5mAhhuxcHbjEHH+JQoxt7hG/2HGFjjwyolLo7hbaexcnhoEuV4e0TNJ8kkpMjiEYY4VQ==" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", diff --git a/package.json b/package.json index ea7b1f920..adc02f179 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "jotai": "^2.7.2", "jotai-effect": "^0.6.0", "lucide-react": "^0.356.0", + "multiformats": "^13.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", diff --git a/src/features/applications/data/application.ts b/src/features/applications/data/application.ts index 45f94a5ae..efe65be8d 100644 --- a/src/features/applications/data/application.ts +++ b/src/features/applications/data/application.ts @@ -29,7 +29,9 @@ export const getApplicationAtomBuilder = (store: JotaiStore, applicationId: Appl try { const applicationResult = await get(fetchApplicationResultAtom) set(applicationResultsAtom, (prev) => { - return prev.set(applicationResult.id, applicationResult) + const next = new Map(prev) + next.set(applicationResult.id, applicationResult) + return next }) } catch (e) { // Ignore any errors as there is nothing to sync diff --git a/src/features/assets/components/asset-details.tsx b/src/features/assets/components/asset-details.tsx index da490c960..609b48865 100644 --- a/src/features/assets/components/asset-details.tsx +++ b/src/features/assets/components/asset-details.tsx @@ -7,35 +7,49 @@ import { isDefined } from '@/utils/is-defined' import Decimal from 'decimal.js' import { AccountLink } from '@/features/accounts/components/account-link' import { ZERO_ADDRESS } from '@/features/common/constants' +import { + assetAddressesLabel, + assetClawbackLabel, + assetCreatorLabel, + assetDecimalsLabel, + assetDefaultFrozenLabel, + assetDetailsLabel, + assetFreezeLabel, + assetIdLabel, + assetJsonLabel, + assetManagerLabel, + assetNameLabel, + assetReserveLabel, + assetTotalSupplyLabel, + assetTransactionsLabel, + assetUnitNameLabel, + assetUrlLabel, +} from './labels' +import { Badge } from '@/features/common/components/badge' +import { AssetMedia } from './asset-media' +import { AssetTraits } from './asset-traits' +import { AssetMetadata } from './asset-metadata' type Props = { asset: Asset } -export const assetIdLabel = 'Asset ID' -export const assetNameLabel = 'Name' -export const assetUnitNameLabel = 'Unit' -export const assetDecimalsLabel = 'Decimals' -export const assetTotalSupplyLabel = 'Total Supply' -export const assetMetadataHashLabel = 'Metadata Hash' -export const assetDefaultFrozenLabel = 'Default Frozen' -export const assetUrlLabel = 'URL' - -export const assetAddressesLabel = 'Asset Addresses' -export const assetCreatorLabel = 'Creator' -export const assetManagerLabel = 'Manager' -export const assetReserveLabel = 'Reserve' -export const assetFreezeLabel = 'Freeze' -export const assetClawbackLabel = 'Clawback' - -export const assetJsonLabel = 'Asset JSON' - export function AssetDetails({ asset }: Props) { const assetItems = useMemo( () => [ { dt: assetIdLabel, - dd: asset.id, + dd: ( + <> + {asset.id} + {asset.standardsUsed.map((s, i) => ( + + {s} + + ))} + {asset.type} + + ), }, asset.name ? { @@ -51,7 +65,7 @@ export function AssetDetails({ asset }: Props) { : undefined, { dt: assetTotalSupplyLabel, - dd: `${new Decimal(asset.total.toString()).div(new Decimal(10).pow(asset.decimals.toString()))} ${asset.unitName}`, + dd: `${new Decimal(asset.total.toString()).div(new Decimal(10).pow(asset.decimals.toString()))} ${asset.unitName ?? ''}`, }, { dt: assetDecimalsLabel, @@ -72,7 +86,7 @@ export function AssetDetails({ asset }: Props) { } : undefined, ], - [asset.decimals, asset.defaultFrozen, asset.id, asset.name, asset.total, asset.unitName, asset.url] + [asset.id, asset.name, asset.standardsUsed, asset.type, asset.unitName, asset.total, asset.decimals, asset.defaultFrozen, asset.url] ).filter(isDefined) const assetAddresses = useMemo( @@ -111,25 +125,42 @@ export function AssetDetails({ asset }: Props) { return (
- - - - - - + -

{assetAddressesLabel}

- -
-
- - -

{assetJsonLabel}

-
-
{asset.json}
+
+ +
+ {asset.id !== 0 && ( + <> + + +

{assetAddressesLabel}

+ +
+
+ + + + + +

{assetJsonLabel}

+
+
{asset.json}
+
+
+
+ + + +

{assetTransactionsLabel}

+
{/* */}
+
+
+ + )}
) } diff --git a/src/features/assets/components/asset-media.tsx b/src/features/assets/components/asset-media.tsx new file mode 100644 index 000000000..d2e5004be --- /dev/null +++ b/src/features/assets/components/asset-media.tsx @@ -0,0 +1,19 @@ +import { cn } from '@/features/common/utils' +import { Asset, AssetMediaType } from '../models' + +type Props = { + asset: Asset +} + +export function AssetMedia({ asset }: Props) { + return asset.media ? ( +
+ {asset.media.type === AssetMediaType.Image && {asset.name}} + {asset.media.type === AssetMediaType.Video && ( + + )} +
+ ) : null +} diff --git a/src/features/assets/components/asset-metadata.tsx b/src/features/assets/components/asset-metadata.tsx new file mode 100644 index 000000000..8cb1aaa63 --- /dev/null +++ b/src/features/assets/components/asset-metadata.tsx @@ -0,0 +1,43 @@ +import { Card, CardContent } from '@/features/common/components/card' +import { cn } from '@/features/common/utils' +import { Asset } from '../models' +import { assetMetadataLabel } from './labels' +import { useMemo } from 'react' +import { DescriptionList } from '@/features/common/components/description-list' + +type Props = { + metadata: Asset['metadata'] +} + +export function AssetMetadata({ metadata }: Props) { + const items = useMemo(() => { + return Object.entries(metadata ?? {}).map(([key, value]) => { + return { + dt: humanisePropertyKey(key), + dd: value, + } + }) + }, [metadata]) + + if (items.length === 0) { + return undefined + } + + return ( + + +

{assetMetadataLabel}

+ +
+
+ ) +} + +const humanisePropertyKey = (key: string): string => { + const upperCaseFirstWord = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1) + } + + const chunks = key.split('_') + return chunks.map(upperCaseFirstWord).join(' ') +} diff --git a/src/features/assets/components/asset-traits.tsx b/src/features/assets/components/asset-traits.tsx new file mode 100644 index 000000000..c27bec630 --- /dev/null +++ b/src/features/assets/components/asset-traits.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent } from '@/features/common/components/card' +import { cn } from '@/features/common/utils' +import { Asset } from '../models' +import { assetTraitsLabel } from './labels' +import { useMemo } from 'react' +import { DescriptionList } from '@/features/common/components/description-list' + +type Props = { + traits: Asset['traits'] +} + +export function AssetTraits({ traits }: Props) { + const items = useMemo(() => { + return Object.entries(traits ?? {}).map(([key, value]) => { + return { + dt: key, + dd: value, + } + }) + }, [traits]) + + if (items.length === 0) { + return undefined + } + + return ( + + +

{assetTraitsLabel}

+ +
+
+ ) +} diff --git a/src/features/assets/components/labels.ts b/src/features/assets/components/labels.ts new file mode 100644 index 000000000..443c9dffd --- /dev/null +++ b/src/features/assets/components/labels.ts @@ -0,0 +1,25 @@ +export const assetDetailsLabel = 'Asset Details' +export const assetIdLabel = 'Asset ID' +export const assetNameLabel = 'Name' +export const assetDescriptionLabel = 'Description' +export const assetUnitNameLabel = 'Unit' +export const assetDecimalsLabel = 'Decimals' +export const assetTotalSupplyLabel = 'Total Supply' +export const assetMetadataHashLabel = 'Metadata Hash' +export const assetDefaultFrozenLabel = 'Default Frozen' +export const assetUrlLabel = 'URL' + +export const assetAddressesLabel = 'Asset Addresses' +export const assetCreatorLabel = 'Creator' +export const assetManagerLabel = 'Manager' +export const assetReserveLabel = 'Reserve' +export const assetFreezeLabel = 'Freeze' +export const assetClawbackLabel = 'Clawback' + +export const assetTraitsLabel = 'Asset Traits' + +export const assetMetadataLabel = 'Asset Metadata' + +export const assetJsonLabel = 'Asset JSON' + +export const assetTransactionsLabel = 'Asset Transactions' diff --git a/src/features/assets/data/asset-metadata.ts b/src/features/assets/data/asset-metadata.ts new file mode 100644 index 000000000..0417b4c03 --- /dev/null +++ b/src/features/assets/data/asset-metadata.ts @@ -0,0 +1,147 @@ +import { atom } from 'jotai' +import { JotaiStore } from '@/features/common/data/types' +import { atomEffect } from 'jotai-effect' +import { assetMetadataAtom } from './core' +import { AssetResult, TransactionResult, TransactionSearchResults } from '@algorandfoundation/algokit-utils/types/indexer' +import { indexer } from '@/features/common/data' +import { flattenTransactionResult } from '@/features/transactions/utils/flatten-transaction-result' +import { TransactionType } from 'algosdk' +import { Arc3MetadataResult, Arc69MetadataResult, AssetMetadataResult, AssetMetadataStandard } from './types' +import { getArc19Url, isArc19Url } from '../utils/arc19' +import { getArc3Url, isArc3Url } from '../utils/arc3' +import { base64ToUtf8 } from '@/utils/base64-to-utf8' +import { ZERO_ADDRESS } from '@/features/common/constants' +import { executePaginatedRequest } from '@algorandfoundation/algokit-utils' + +// Currently, we support ARC-3, 19 and 69. Their specs can be found here https://github.com/algorandfoundation/ARCs/tree/main/ARCs +// ARCs are community standard, therefore, there are edge cases +// For example: +// - An asset can follow ARC-69 and ARC-19 at the same time: https://allo.info/asset/1559471783/nft +// - An asset can follow ARC-3 and ARC-19 at the same time: https://allo.info/asset/1494117806/nft +// - ARC-19 doesn't specify the metadata format but generally people use the ARC-3 format +export const buildAssetMetadataResult = async ( + assetResult: AssetResult, + latestAssetCreateOrReconfigureTransaction?: TransactionResult +): Promise => { + // Get ARC-69 metadata if applicable + if (latestAssetCreateOrReconfigureTransaction && latestAssetCreateOrReconfigureTransaction.note) { + const metadata = noteToArc69Metadata(latestAssetCreateOrReconfigureTransaction.note) + if (metadata) { + return { + standard: AssetMetadataStandard.ARC69, + metadata, + } satisfies Arc69MetadataResult + } + } + + // Get ARC-3 or ARC-19 metadata if applicable + const [isArc3, isArc19] = assetResult.params.url + ? ([isArc3Url(assetResult.params.url), isArc19Url(assetResult.params.url)] as const) + : [false, false] + + if (assetResult.params.url && (isArc3 || isArc19)) { + // If the asset follows both ARC-3 and ARC-19, we build the ARC-19 url + const metadataUrl = isArc19 + ? getArc19Url(assetResult.params.url, assetResult.params.reserve) + : getArc3Url(assetResult.index, assetResult.params.url) + + if (metadataUrl) { + const response = await fetch(metadataUrl) + const { localization: _localization, ...metadata } = await response.json() + return { + standard: AssetMetadataStandard.ARC3, + metadata_url: metadataUrl, + metadata, + } satisfies Arc3MetadataResult + } + } + + return null +} + +const noteToArc69Metadata = (note: string | undefined) => { + if (!note) { + return undefined + } + + const json = base64ToUtf8(note) + if (json.match(/^{/) && json.includes('arc69')) { + return JSON.parse(json) as Arc69MetadataResult['metadata'] + } + return undefined +} + +export const fetchAssetMetadataAtomBuilder = (assetResult: AssetResult) => + atom(async (_get) => { + if (assetResult.index === 0) { + return null + } + + const results = + assetResult.params.manager && assetResult.params.manager !== ZERO_ADDRESS + ? await indexer + .searchForTransactions() + .assetID(assetResult.index) + .txType('acfg') + .address(assetResult.params.manager) + .addressRole('sender') + .limit(2) // Return 2 to cater for a destroy transaction and any potential eventual consistency delays between transactions and assets. + .do() + .then((res) => res.transactions as TransactionResult[]) // Implicitly newest to oldest when filtering with an address + : // The asset has been destroyed or is an immutable asset. + // Fetch the entire acfg transaction history and reverse the order, so it's newest to oldest + await executePaginatedRequest( + (res: TransactionSearchResults) => res.transactions, + (nextToken) => { + let s = indexer.searchForTransactions().assetID(assetResult.index).txType('acfg') + if (nextToken) { + s = s.nextToken(nextToken) + } + return s + } + ).then((res) => res.reverse()) // reverse the order, so it's newest to oldest + + const assetConfigTransactionResults = results.flatMap(flattenTransactionResult).filter((t) => { + const isAssetConfigTransaction = t['tx-type'] === TransactionType.acfg + const isDestroyTransaction = t['asset-config-transaction']?.['params'] === undefined + return isAssetConfigTransaction && !isDestroyTransaction + }) + + if (assetConfigTransactionResults.length === 0) { + return null + } + + return await buildAssetMetadataResult(assetResult, assetConfigTransactionResults[0]) + }) + +export const getAssetMetadataAtomBuilder = (store: JotaiStore, assetResult: AssetResult) => { + const fetchAssetMetadataAtom = fetchAssetMetadataAtomBuilder(assetResult) + + const syncEffect = atomEffect((get, set) => { + ;(async () => { + try { + const assetMetadata = await get(fetchAssetMetadataAtom) + set(assetMetadataAtom, (prev) => { + const next = new Map(prev) + next.set(assetResult.index, assetMetadata) + return next + }) + } catch (e) { + // Ignore any errors as there is nothing to sync + } + })() + }) + + return atom(async (get) => { + const assetMetadata = store.get(assetMetadataAtom) + const cachedAssetMetadata = assetMetadata.get(assetResult.index) + if (cachedAssetMetadata) { + return cachedAssetMetadata + } + + get(syncEffect) + + const assetMetadataResult = await get(fetchAssetMetadataAtom) + return assetMetadataResult + }) +} diff --git a/src/features/assets/data/asset-result.ts b/src/features/assets/data/asset-result.ts new file mode 100644 index 000000000..b94d8b126 --- /dev/null +++ b/src/features/assets/data/asset-result.ts @@ -0,0 +1,59 @@ +import { atom } from 'jotai' +import { AssetIndex, AssetResult } from './types' +import { indexer, algod } from '@/features/common/data' +import { atomEffect } from 'jotai-effect' +import { assetResultsAtom } from './core' +import { JotaiStore } from '@/features/common/data/types' +import { asError, is404 } from '@/utils/error' + +export const fetchAssetResultAtomBuilder = (assetIndex: AssetIndex) => + atom(async (_get) => { + try { + // Check algod first, as there can be some syncing delays to indexer + return await algod + .getAssetByID(assetIndex) + .do() + .then((result) => result as AssetResult) + } catch (e: unknown) { + if (is404(asError(e))) { + // Handle destroyed assets or assets that may not be available in algod potentially due to the node type + return await indexer + .lookupAssetByID(assetIndex) + .includeAll(true) // Returns destroyed assets + .do() + .then((result) => result.asset as AssetResult) + } + throw e + } + }) + +export const getAssetResultAtomBuilder = (store: JotaiStore, assetIndex: AssetIndex) => { + return atom(async (get) => { + // TODO: NC - If I don't use store here we get double fetching when an atom depends on this, due to depending on something that we directly set using an effect. + // I'll be coming back and re-evaluating the patterns here. + const assetResults = store.get(assetResultsAtom) + const cachedAssetResult = assetResults.get(assetIndex) + if (cachedAssetResult) { + return cachedAssetResult + } + + const fetchAssetResultAtom = fetchAssetResultAtomBuilder(assetIndex) + const syncEffect = atomEffect((get, set) => { + ;(async () => { + try { + const assetResult = await get(fetchAssetResultAtom) + + set(assetResultsAtom, (prev) => { + const next = new Map(prev) + next.set(assetResult.index, assetResult) + return next + }) + } catch (e) { + // Ignore any errors as there is nothing to sync + } + })() + }) + get(syncEffect) + return await get(fetchAssetResultAtom) + }) +} diff --git a/src/features/assets/data/asset-summary.ts b/src/features/assets/data/asset-summary.ts new file mode 100644 index 000000000..e66965a50 --- /dev/null +++ b/src/features/assets/data/asset-summary.ts @@ -0,0 +1,31 @@ +import { atom, useAtomValue, useStore } from 'jotai' +import { JotaiStore } from '@/features/common/data/types' +import { useMemo } from 'react' +import { loadable } from 'jotai/utils' +import { asAssetSummary } from '../mappers/asset-summary' +import { AssetIndex } from './types' +import { getAssetResultAtomBuilder } from './asset-result' + +export const getAssetSummaryAtomBuilder = (store: JotaiStore, assetIndex: AssetIndex) => { + return atom(async (get) => { + const assetResult = await get(getAssetResultAtomBuilder(store, assetIndex)) + return asAssetSummary(assetResult) + }) +} + +export const getAssetSummariesAtomBuilder = (store: JotaiStore, assetIndexes: AssetIndex[]) => { + return atom((get) => { + return Promise.all(assetIndexes.map((assetIndex) => get(getAssetSummaryAtomBuilder(store, assetIndex)))) + }) +} + +export const useAssetSummaryAtom = (assetIndex: AssetIndex) => { + const store = useStore() + return useMemo(() => { + return getAssetSummaryAtomBuilder(store, assetIndex) + }, [store, assetIndex]) +} + +export const useLoadableAssetSummary = (assetIndex: AssetIndex) => { + return useAtomValue(loadable(useAssetSummaryAtom(assetIndex))) +} diff --git a/src/features/assets/data/asset.ts b/src/features/assets/data/asset.ts index 531e88bdb..862eb9498 100644 --- a/src/features/assets/data/asset.ts +++ b/src/features/assets/data/asset.ts @@ -1,63 +1,24 @@ -import { indexer } from '@/features/common/data' -import { AssetLookupResult } from '@algorandfoundation/algokit-utils/types/indexer' import { atom, useAtomValue, useStore } from 'jotai' import { JotaiStore } from '@/features/common/data/types' -import { atomEffect } from 'jotai-effect' -import { assetResultsAtom } from './core' import { useMemo } from 'react' -import { loadable } from 'jotai/utils' -import { asAsset } from '../mappers' import { AssetIndex } from './types' +import { loadable } from 'jotai/utils' +import { getAssetResultAtomBuilder } from './asset-result' +import { getAssetMetadataAtomBuilder } from './asset-metadata' +import { asAsset } from '../mappers/asset' -const fetchAssetResultAtomBuilder = (assetIndex: AssetIndex) => - atom(async (_get) => { - return await indexer - .lookupAssetByID(assetIndex) - .includeAll(true) - .do() - .then((result) => { - return (result as AssetLookupResult).asset - }) - }) - -export const getAssetAtomBuilder = (store: JotaiStore, assetIndex: AssetIndex) => { - const fetchAssetResultAtom = fetchAssetResultAtomBuilder(assetIndex) - - const syncEffect = atomEffect((get, set) => { - ;(async () => { - try { - const assetResult = await get(fetchAssetResultAtom) - set(assetResultsAtom, (prev) => { - return prev.set(assetResult.index, assetResult) - }) - } catch (e) { - // Ignore any errors as there is nothing to sync - } - })() - }) +const getAssetAtomBuilder = (store: JotaiStore, assetIndex: AssetIndex) => { + const assetResultAtom = getAssetResultAtomBuilder(store, assetIndex) return atom(async (get) => { - const assetResults = store.get(assetResultsAtom) - const cachedAssetResult = assetResults.get(assetIndex) - if (cachedAssetResult) { - return asAsset(cachedAssetResult) - } - - get(syncEffect) - - const assetResult = await get(fetchAssetResultAtom) - return asAsset(assetResult) - }) -} - -export const getAssetsAtomBuilder = (store: JotaiStore, assetIndexes: AssetIndex[]) => { - return atom((get) => { - return Promise.all(assetIndexes.map((assetIndex) => get(getAssetAtomBuilder(store, assetIndex)))) + const assetResult = await get(assetResultAtom) + return asAsset(assetResult, await get(getAssetMetadataAtomBuilder(store, assetResult))) }) } export const useAssetAtom = (assetIndex: AssetIndex) => { const store = useStore() + return useMemo(() => { return getAssetAtomBuilder(store, assetIndex) }, [store, assetIndex]) diff --git a/src/features/assets/data/core.ts b/src/features/assets/data/core.ts index 9b8328a86..96241e996 100644 --- a/src/features/assets/data/core.ts +++ b/src/features/assets/data/core.ts @@ -1,5 +1,5 @@ import { AssetResult } from '@algorandfoundation/algokit-utils/types/indexer' -import { AssetIndex } from './types' +import { AssetIndex, AssetMetadataResult } from './types' import { atom } from 'jotai' import { ZERO_ADDRESS } from '@/features/common/constants' @@ -17,3 +17,5 @@ export const algoAssetResult = { } as AssetResult export const assetResultsAtom = atom>(new Map([[algoAssetResult.index, algoAssetResult]])) + +export const assetMetadataAtom = atom>(new Map()) diff --git a/src/features/assets/data/index.ts b/src/features/assets/data/index.ts index a1e9ae954..551e325d7 100644 --- a/src/features/assets/data/index.ts +++ b/src/features/assets/data/index.ts @@ -1 +1,3 @@ +export * from './asset-summary' export * from './asset' +export * from './asset-result' diff --git a/src/features/assets/data/types.ts b/src/features/assets/data/types.ts index 211e96d89..a07049a01 100644 --- a/src/features/assets/data/types.ts +++ b/src/features/assets/data/types.ts @@ -1 +1,56 @@ +import { AssetParams } from '@algorandfoundation/algokit-utils/types/indexer' + export type AssetIndex = number + +export type AssetResult = { + index: AssetIndex + params: AssetParams +} + +type Arc16MetadataProperties = { + traits?: Record + [key: string]: unknown +} + +// ARC-3 and ARC-19 share the same metadata structure, which is defined in the ARC-3 spec +export type Arc3MetadataResult = { + standard: AssetMetadataStandard.ARC3 + metadata_url: string + metadata: { + name?: string + decimals?: number + description?: string + image?: string + image_integrity?: string + image_mimetype?: string + background_color?: string + external_url?: string + external_url_integrity?: string + external_url_mimetype?: string + animation_url?: string + animation_url_integrity?: string + animation_url_mimetype?: string + properties?: Arc16MetadataProperties + extra_metadata?: string + [key: string]: unknown + } +} + +export type Arc69MetadataResult = { + standard: AssetMetadataStandard.ARC69 + metadata: { + description?: string + external_url?: string + media_url?: string + properties?: Arc16MetadataProperties + mime_type?: string + [key: string]: unknown + } +} + +export enum AssetMetadataStandard { + ARC3 = 'ARC-3', + ARC69 = 'ARC-69', +} + +export type AssetMetadataResult = Arc3MetadataResult | Arc69MetadataResult | null diff --git a/src/features/assets/mappers/asset-summary.ts b/src/features/assets/mappers/asset-summary.ts new file mode 100644 index 000000000..8a605ed97 --- /dev/null +++ b/src/features/assets/mappers/asset-summary.ts @@ -0,0 +1,12 @@ +import { AssetResult } from '@algorandfoundation/algokit-utils/types/indexer' +import { AssetSummary } from '../models' + +export const asAssetSummary = (assetResult: AssetResult): AssetSummary => { + return { + id: assetResult.index, + name: assetResult.params.name, + decimals: assetResult.params.decimals, + unitName: assetResult.params['unit-name'], + clawback: assetResult.params.clawback, + } +} diff --git a/src/features/assets/mappers/asset.ts b/src/features/assets/mappers/asset.ts new file mode 100644 index 000000000..b04255b3b --- /dev/null +++ b/src/features/assets/mappers/asset.ts @@ -0,0 +1,151 @@ +import { AssetResult } from '@algorandfoundation/algokit-utils/types/indexer' +import { asAssetSummary } from './asset-summary' +import { Asset, AssetMediaType, AssetStandard, AssetType } from '../models' +import { AssetIndex, AssetMetadataResult, AssetMetadataStandard } from '../data/types' +import { getArc3Url, isArc3Url } from '../utils/arc3' +import { replaceIpfsWithGatewayIfNeeded } from '../utils/replace-ipfs-with-gateway-if-needed' +import Decimal from 'decimal.js' +import { asJson } from '@/utils/as-json' +import { getArc19Url, isArc19Url } from '../utils/arc19' +import { isArc16Properties } from '../utils/arc16' + +export const asAsset = (assetResult: AssetResult, metadataResult: AssetMetadataResult): Asset => { + return { + ...asAssetSummary(assetResult), + total: assetResult.params.total, + defaultFrozen: assetResult.params['default-frozen'] ?? false, + url: assetResult.params.url, + creator: assetResult.params.creator, + manager: assetResult.params.manager, + reserve: assetResult.params.reserve, + freeze: assetResult.params.freeze, + type: asType(assetResult), + standardsUsed: asStandardsUsed(assetResult, metadataResult), + traits: asTraits(metadataResult), + media: asMedia(assetResult, metadataResult), + metadata: asMetadata(metadataResult), + json: asJson(assetResult), + } +} + +const asMetadata = (metadataResult: AssetMetadataResult): Asset['metadata'] => { + if (metadataResult) { + const { properties: _properties, ...metadata } = metadataResult.metadata + return normalizeObjectForDisplay(metadata) as Record + } +} + +const asTraits = (metadataResult: AssetMetadataResult): Asset['traits'] => { + if (metadataResult && metadataResult.metadata.properties) { + if (isArc16Properties(metadataResult.metadata.properties)) { + return normalizeObjectForDisplay(metadataResult.metadata.properties.traits!) as Record + } + + return normalizeObjectForDisplay(metadataResult.metadata.properties) as Record + } +} + +const asMedia = (assetResult: AssetResult, metadataResult: AssetMetadataResult): Asset['media'] => { + if (metadataResult && metadataResult.standard === AssetMetadataStandard.ARC69) { + // If the asset follows ARC-69, we display the media from the asset URL + // In this scenario, we also support ARC-19 format URLs + if (!assetResult.params.url) { + return undefined + } + + const url = isArc19Url(assetResult.params.url) + ? getArc19Url(assetResult.params.url, assetResult.params.reserve) + : replaceIpfsWithGatewayIfNeeded(assetResult.params.url) + + if (url) { + return { + type: metadataResult.metadata.mime_type?.startsWith('video/') ? AssetMediaType.Video : AssetMediaType.Image, + url, + } + } + } else if (metadataResult && metadataResult.standard === AssetMetadataStandard.ARC3) { + const metadata = metadataResult.metadata + // If the asset follows ARC-3 or ARC-19, but not ARC-69 + // we use the media from the metadata + const imageUrl = metadata.image && getArc3MediaUrl(assetResult.index, metadataResult.metadata_url, metadata.image) + if (imageUrl) { + return { + url: imageUrl, + type: AssetMediaType.Image, + } + } + const videoUrl = metadata.animation_url && getArc3MediaUrl(assetResult.index, metadataResult.metadata_url, metadata.animation_url) + if (videoUrl) { + return { + url: videoUrl, + type: AssetMediaType.Video, + } + } + } else if (!metadataResult && assetResult.params.url?.startsWith('ipfs://')) { + // There are a lot of NFTs which use the URL to store the ipfs image, however don't follow any standard + return { + url: replaceIpfsWithGatewayIfNeeded(assetResult.params.url), + type: AssetMediaType.Image, + } + } +} + +const asStandardsUsed = (assetResult: AssetResult, metadataResult: AssetMetadataResult): AssetStandard[] => { + const standardsUsed = new Set() + const [isArc3, isArc19] = assetResult.params.url + ? ([isArc3Url(assetResult.params.url), isArc19Url(assetResult.params.url)] as const) + : [false, false] + if (isArc3) { + standardsUsed.add(AssetStandard.ARC3) + } + if (metadataResult?.metadata.properties && isArc16Properties(metadataResult.metadata.properties)) { + standardsUsed.add(AssetStandard.ARC16) + } + if (isArc19) { + standardsUsed.add(AssetStandard.ARC19) + } + if (metadataResult && metadataResult.standard === AssetMetadataStandard.ARC69) { + standardsUsed.add(AssetStandard.ARC69) + } + return Array.from(standardsUsed) +} + +const asType = (assetResult: AssetResult): AssetType => { + if (assetResult.deleted === true) { + return AssetType.Deleted + } + + if (assetResult.params.total === 1 && assetResult.params.decimals === 0) { + return AssetType.PureNonFungible + } + // Check for fractional non-fungible + // Definition from ARC-3 + // An ASA is said to be a fractional non-fungible token (fractional NFT) if and only if it has the following properties: + // Total Number of Units (t) MUST be a power of 10 larger than 1: 10, 100, 1000, ... + // Number of Digits after the Decimal Point (dc) MUST be equal to the logarithm in base 10 of total number of units. + if ( + assetResult.params.total > 1 && + Decimal.log10(assetResult.params.total.toString()).toString() === assetResult.params.decimals.toString() + ) { + return AssetType.FractionalNonFungible + } + return AssetType.Fungible +} + +const getArc3MediaUrl = (assetIndex: AssetIndex, assetMetadataUrl: string, mediaUrl: string) => { + const isRelative = !mediaUrl.includes(':') + const absoluteMediaUrl = !isRelative ? mediaUrl : new URL(assetMetadataUrl, mediaUrl).toString() + + return getArc3Url(assetIndex, absoluteMediaUrl) +} + +const normalizeObjectForDisplay = (object: Record) => { + const converted = Object.entries(object).map(([key, value]) => { + if (typeof value === 'object') { + return [key, JSON.stringify(value)] as const + } + return [key, value as unknown] as const + }) + + return Object.fromEntries(converted) +} diff --git a/src/features/assets/mappers/index.ts b/src/features/assets/mappers/index.ts index dbffa38a6..6edea8b23 100644 --- a/src/features/assets/mappers/index.ts +++ b/src/features/assets/mappers/index.ts @@ -1,21 +1 @@ -import { AssetResult } from '@algorandfoundation/algokit-utils/types/indexer' -import { Asset } from '../models' -import { asJson } from '@/utils/as-json' - -export const asAsset = (assetResult: AssetResult): Asset => { - return { - id: assetResult.index, - name: assetResult.params.name, - total: assetResult.params.total, - decimals: assetResult.params.decimals, - unitName: assetResult.params['unit-name'], - defaultFrozen: assetResult.params['default-frozen'] ?? false, - url: assetResult.params.url, - creator: assetResult.params.creator, - manager: assetResult.params.manager, - reserve: assetResult.params.reserve, - freeze: assetResult.params.freeze, - clawback: assetResult.params.clawback, - json: asJson(assetResult), - } -} +export { asAssetSummary } from './asset-summary' diff --git a/src/features/assets/models/index.ts b/src/features/assets/models/index.ts index 607082b5f..25683396c 100644 --- a/src/features/assets/models/index.ts +++ b/src/features/assets/models/index.ts @@ -1,15 +1,47 @@ -export type Asset = { +export type AssetSummary = { id: number name?: string - total: number | bigint decimals: number | bigint unitName?: string + clawback?: string +} + +export enum AssetType { + Fungible = 'Fungible', + PureNonFungible = 'Pure Non-Fungible', + FractionalNonFungible = 'Fractional Non-Fungible', + Deleted = 'Deleted', +} + +export enum AssetMediaType { + Image = 'image', + Video = 'video', +} + +export type AssetMedia = { + type: AssetMediaType + url: string +} + +export type Asset = AssetSummary & { + total: number | bigint defaultFrozen: boolean url?: string creator: string manager?: string reserve?: string freeze?: string - clawback?: string + type: AssetType + standardsUsed: AssetStandard[] + traits?: Record + metadata?: Record + media?: AssetMedia json: string } + +export enum AssetStandard { + ARC3 = 'ARC-3', + ARC16 = 'ARC-16', + ARC19 = 'ARC-19', + ARC69 = 'ARC-69', +} diff --git a/src/features/assets/pages/asset-page.test.tsx b/src/features/assets/pages/asset-page.test.tsx new file mode 100644 index 000000000..4d3b42c9f --- /dev/null +++ b/src/features/assets/pages/asset-page.test.tsx @@ -0,0 +1,615 @@ +import { assetResultMother } from '@/tests/object-mother/asset-result' +import { executeComponentTest } from '@/tests/test-component' +import { render, waitFor } from '@/tests/testing-library' +import { describe, expect, it, vi } from 'vitest' +import { AssetPage, assetFailedToLoadMessage, assetInvalidIdMessage, assetNotFoundMessage } from './asset-page' +import { descriptionListAssertion } from '@/tests/assertions/description-list-assertion' +import { + assetAddressesLabel, + assetCreatorLabel, + assetDecimalsLabel, + assetDefaultFrozenLabel, + assetDetailsLabel, + assetIdLabel, + assetManagerLabel, + assetMetadataLabel, + assetNameLabel, + assetReserveLabel, + assetTotalSupplyLabel, + assetTraitsLabel, + assetUrlLabel, +} from '../components/labels' +import { useParams } from 'react-router-dom' +import { createStore } from 'jotai' +import { algoAssetResult, assetResultsAtom } from '../data/core' +import { indexer, algod } from '@/features/common/data' +import { transactionResultMother } from '@/tests/object-mother/transaction-result' +import { assetUnitLabel } from '@/features/transactions/components/asset-config-transaction-info' +import { HttpError } from '@/tests/errors' +import { ipfsGatewayUrl } from '../utils/replace-ipfs-with-gateway-if-needed' + +describe('asset-page', () => { + describe('when rending an asset using an invalid asset Id', () => { + it('should display invalid asset Id message', () => { + vi.mocked(useParams).mockImplementation(() => ({ assetId: 'invalid-id' })) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(assetInvalidIdMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rending an asset with asset Id that does not exist', () => { + it('should display not found message', () => { + vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) + vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) + vi.mocked(indexer.lookupAssetByID(0).includeAll(true).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(assetNotFoundMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rending an asset that failed to load', () => { + it('should display failed to load message', () => { + vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) + vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject({})) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(assetFailedToLoadMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rendering an ARC-3 asset', () => { + const assetResult = assetResultMother['mainnet-1284444444']().build() + const transactionResult = transactionResultMother.assetConfig().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked(fetch).mockImplementation(() => + Promise.resolve({ + json: () => + Promise.resolve({ + name: 'Orange', + decimals: 8, + description: + "John Alan Woods 01/Dec/2023 You know, I can pull metrics out of the air too, whatever, 8 million transactions over the last week, I don't know, my mom has four oranges.", + image: 'ipfs://QmaEGBYWLQWDqMMR9cwpX3t4xoRuJpz5kzCwwdQmWaxHXv', + image_integrity: 'sha256-hizgBlZvh1teH9kzMnkocf2q9L7zpjLQZghQfKThVRg=', + image_mimetype: 'image/png', + }), + } as Response) + ) + + vi.mocked( + indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').address('').addressRole('sender').limit(2).do().then + ).mockImplementation(() => Promise.resolve([transactionResult])) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '1284444444ARC-3Fungible' }, + { term: assetNameLabel, description: 'Orange' }, + { term: assetUnitLabel, description: 'ORA' }, + { term: assetTotalSupplyLabel, description: '4000000 ORA' }, + { term: assetDecimalsLabel, description: '8' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'ipfs://QmUitxJuPJJrcuAdAiVdEEpuzGmsELGgAvhLd5FiXRShEu#arc3' }, + ], + }) + expect(detailsCard.querySelector(`img[src="${ipfsGatewayUrl}QmaEGBYWLQWDqMMR9cwpX3t4xoRuJpz5kzCwwdQmWaxHXv"]`)).toBeTruthy() + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [ + { term: assetCreatorLabel, description: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI' }, + { term: assetManagerLabel, description: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI' }, + { term: assetReserveLabel, description: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI' }, + ], + }) + + const assetMetadataCard = component.getByText(assetMetadataLabel).parentElement! + descriptionListAssertion({ + container: assetMetadataCard, + items: [ + { term: 'Name', description: 'Orange' }, + { term: 'Decimals', description: '8' }, + { + term: 'Description', + description: + "John Alan Woods 01/Dec/2023 You know, I can pull metrics out of the air too, whatever, 8 million transactions over the last week, I don't know, my mom has four oranges.", + }, + { term: 'Image', description: 'ipfs://QmaEGBYWLQWDqMMR9cwpX3t4xoRuJpz5kzCwwdQmWaxHXv' }, + { term: 'Image Integrity', description: 'sha256-hizgBlZvh1teH9kzMnkocf2q9L7zpjLQZghQfKThVRg=' }, + { term: 'Image Mimetype', description: 'image/png' }, + ], + }) + }) + } + ) + }) + }) + + describe('when rendering an ARC-3 + ARC-19 asset', () => { + const assetResult = assetResultMother['mainnet-1494117806']().build() + const transactionResult = transactionResultMother.assetConfig().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked(fetch).mockImplementation(() => + Promise.resolve({ + json: () => + Promise.resolve({ + name: 'Zappy #1620', + standard: 'arc3', + decimals: 0, + image: 'ipfs://bafkreicfzgycn6zwhmegqjfnsj4q4qkff2luu3tzfrxtv5qpra5buf7d74', + image_mimetype: 'image/png', + properties: { + Background: 'Orange', + Body: 'Turtleneck Sweater', + Earring: 'Right Helix', + Eyes: 'Wet', + Eyewear: 'Nerd Glasses', + Head: 'Wrap', + Mouth: 'Party Horn', + Skin: 'Sienna', + }, + }), + } as Response) + ) + vi.mocked( + indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').address('').addressRole('sender').limit(2).do().then + ).mockImplementation(() => Promise.resolve([transactionResult])) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '1494117806ARC-3ARC-19Pure Non-Fungible' }, + { term: assetNameLabel, description: 'Zappy #1620' }, + { term: assetUnitLabel, description: 'ZAPP1620' }, + { term: assetTotalSupplyLabel, description: '1 ZAPP1620' }, + { term: assetDecimalsLabel, description: '0' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}#arc3' }, + ], + }) + expect( + detailsCard.querySelector(`img[src="${ipfsGatewayUrl}bafkreicfzgycn6zwhmegqjfnsj4q4qkff2luu3tzfrxtv5qpra5buf7d74"]`) + ).toBeTruthy() + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [ + { term: assetCreatorLabel, description: 'UF5DSSCT3GO62CSTSFB4QN5GNKFIMO7HCF2OIY6D57Z37IETEXRKUUNOPU' }, + { term: assetManagerLabel, description: 'UF5DSSCT3GO62CSTSFB4QN5GNKFIMO7HCF2OIY6D57Z37IETEXRKUUNOPU' }, + { term: assetReserveLabel, description: 'OPL3M2ZOKLSPVIM32MRK45O6IQMHTJPVWOWPVTEGXVHC3GHFLJK2YC5OWE' }, + ], + }) + + const assetMetadataCard = component.getByText(assetMetadataLabel).parentElement! + descriptionListAssertion({ + container: assetMetadataCard, + items: [ + { term: 'Name', description: 'Zappy #1620' }, + { term: 'Standard', description: 'arc3' }, + { term: 'Decimals', description: '0' }, + { term: 'Image', description: 'ipfs://bafkreicfzgycn6zwhmegqjfnsj4q4qkff2luu3tzfrxtv5qpra5buf7d74' }, + { term: 'Image Mimetype', description: 'image/png' }, + ], + }) + + const assetTraitsCard = component.getByText(assetTraitsLabel).parentElement! + descriptionListAssertion({ + container: assetTraitsCard, + items: [ + { term: 'Background', description: 'Orange' }, + { term: 'Body', description: 'Turtleneck Sweater' }, + { term: 'Earring', description: 'Right Helix' }, + { term: 'Eyes', description: 'Wet' }, + { term: 'Eyewear', description: 'Nerd Glasses' }, + { term: 'Head', description: 'Wrap' }, + { term: 'Mouth', description: 'Party Horn' }, + { term: 'Skin', description: 'Sienna' }, + ], + }) + }) + } + ) + }) + }) + + describe('when rendering an ARC-69 asset', () => { + const assetResult = assetResultMother['mainnet-1800979729']().build() + const transactionResult = transactionResultMother['mainnet-4BFQTYKSJNRF52LXCMBXKDWLODRDVGSUCW36ND3B7C3ZQKPMLUJA']().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked( + indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').address('').addressRole('sender').limit(2).do().then + ).mockImplementation(() => Promise.resolve([transactionResult])) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '1800979729ARC-69Pure Non-Fungible' }, + { term: assetNameLabel, description: 'DHMα: M1 Solar Flare SCQCSO' }, + { term: assetUnitLabel, description: 'SOLFLARE' }, + { term: assetTotalSupplyLabel, description: '1 SOLFLARE' }, + { term: assetDecimalsLabel, description: '0' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'https://assets.datahistory.org/solar/SCQCSO.mp4#v' }, + ], + }) + expect(detailsCard.querySelector('video>source[src="https://assets.datahistory.org/solar/SCQCSO.mp4#v"]')).toBeTruthy() + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [ + { term: assetCreatorLabel, description: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU' }, + { term: assetManagerLabel, description: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU' }, + { term: assetReserveLabel, description: 'ESK3ZHVALWTRWTEQVRO4ZGZGGOFCKCJNVE5ODFMPWICXVSJVJZYINHHYHE' }, + ], + }) + + const assetMetadataCard = component.getByText(assetMetadataLabel).parentElement! + descriptionListAssertion({ + container: assetMetadataCard, + items: [ + { term: 'Standard', description: 'arc69' }, + { + term: 'Description', + description: + 'This is an alpha data artifact minted by The Data History Museum. It represents a Class M1.6 solar flare. The verified source of this data artifact was the National Oceanic and Atmospheric Administration (NOAA). For more information visit https://datahistory.org/.', + }, + { term: 'External Url', description: 'https://museum.datahistory.org/event/SOLFLARE/SCQCSO' }, + { term: 'Mime Type', description: 'video/mp4' }, + { term: 'Id', description: 'SCQCSO' }, + { term: 'Title', description: 'Class M1.6 solar flare that peaked at Tue, 30 Apr 2024 01:14:00 GMT' }, + ], + }) + + const assetTraitsCard = component.getByText(assetTraitsLabel).parentElement! + descriptionListAssertion({ + container: assetTraitsCard, + items: [ + { term: 'satellite', description: 'GOES-16' }, + { term: 'source', description: 'NOAA' }, + { term: 'beginTime', description: '2024-04-30T00:46:00Z' }, + { term: 'beginClass', description: 'C1.1' }, + { term: 'peakClass', description: 'M1.6' }, + { term: 'peakTime', description: '2024-04-30T01:14:00Z' }, + { term: 'peakXrayFlux', description: '1.64110e-5 Wm⁻²' }, + { term: 'endTime', description: '2024-04-30T01:31:00Z' }, + { term: 'endClass', description: 'C8.3' }, + { term: 'type', description: 'solar' }, + { term: 'subType', description: 'flare' }, + ], + }) + }) + } + ) + }) + }) + + describe('when rendering an ARC-19 + ARC-69 asset', () => { + const assetResult = assetResultMother['mainnet-854081201']().build() + const transactionResult = transactionResultMother['mainnet-P4IX7SYWTTFRQGYTCLFOZSTYSJ5FJKNR3MEIVRR4OA2JJXTQZHTQ']().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked( + indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').address('').addressRole('sender').limit(2).do().then + ).mockImplementation(() => Promise.resolve([transactionResult])) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '854081201ARC-19ARC-69Pure Non-Fungible' }, + { term: assetNameLabel, description: 'Bad Bunny Society #587' }, + { term: assetUnitLabel, description: 'bbs587' }, + { term: assetTotalSupplyLabel, description: '1 bbs587' }, + { term: assetDecimalsLabel, description: '0' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}' }, + ], + }) + expect( + detailsCard.querySelector(`img[src="${ipfsGatewayUrl}bafkreifpfaqwwfyj2zcy76hr6eswkhbqak5bxjzhryeeg7tqnzjgmx5xfi"]`) + ).toBeTruthy() + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [ + { term: assetCreatorLabel, description: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE' }, + { term: assetManagerLabel, description: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE' }, + { term: assetReserveLabel, description: 'V4UCC2YXBHLELD7Y6HYSKZI4GABLUG5HE6HAQQ36OBXFEZS7W4VMWB6DUQ' }, + ], + }) + + const assetMetadataCard = component.getByText(assetMetadataLabel).parentElement! + descriptionListAssertion({ + container: assetMetadataCard, + items: [ + { term: 'Standard', description: 'arc69' }, + { term: 'Description', description: 'Bad Bunny Society #587' }, + { term: 'Mime Type', description: 'image/webp' }, + ], + }) + + const assetTraitsCard = component.getByText(assetTraitsLabel).parentElement! + descriptionListAssertion({ + container: assetTraitsCard, + items: [ + { term: 'Background', description: 'Red' }, + { term: 'Skin', description: 'Pink' }, + { term: 'Ear', description: 'Multicolor' }, + { term: 'Body', description: 'Orange Jacket' }, + { term: 'Mouth', description: 'Joint' }, + { term: 'Nose', description: 'Acid' }, + { term: 'Eyes', description: 'Rave' }, + { term: 'Head', description: 'Ring' }, + ], + }) + }) + } + ) + }) + }) + + describe('when rendering an ARC16 + ARC-19 asset', () => { + const assetResult = assetResultMother['mainnet-1820067164']().build() + const transactionResult = transactionResultMother['mainnet-K66JS73E3BDJ4OYHIC4QRRNSGY2PQMKSQMPYFQ6EEYJTOIPDUA3Q']().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked(fetch).mockImplementation(() => + Promise.resolve({ + json: () => + Promise.resolve({ + name: 'Coop #48', + standard: 'arc3', + image: 'ipfs://bafybeigx4jqvsvkxdflvwvr2bmurrlugv4ulbgw7juhkd3rz52w32enwoy/48.png', + image_mime_type: 'image/png', + description: + "A troop of 890 Coopers based on Cooper Daniels' DEV'N and his quest for CoopCoin Beach. Artwork by F.o.E. and inspired by the original work of Blockrunner for the ReCoop Show.", + properties: { + traits: { + Background: 'Soft Cream', + Base: 'Coop v1', + Tat: 'Naked', + 'Chest Hair': 'Clean', + Outfit: 'Coop Hoodie', + 'Face Tat': 'Clean', + 'Face Trait': 'Gold Grill', + 'Lower Face': 'Fresh Trim', + 'Upper Head': 'Scoopy', + Eyes: 'Coop Brokelys', + Ears: 'Hoop', + }, + filters: {}, + }, + extra: {}, + }), + } as Response) + ) + vi.mocked( + indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').address('').addressRole('sender').limit(2).do().then + ).mockImplementation(() => Promise.resolve([transactionResult])) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '1820067164ARC-16ARC-19Pure Non-Fungible' }, + { term: assetNameLabel, description: 'Coop #48' }, + { term: assetUnitLabel, description: 'Coop48' }, + { term: assetTotalSupplyLabel, description: '1 Coop48' }, + { term: assetDecimalsLabel, description: '0' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}' }, + ], + }) + expect( + detailsCard.querySelector(`img[src="${ipfsGatewayUrl}bafybeigx4jqvsvkxdflvwvr2bmurrlugv4ulbgw7juhkd3rz52w32enwoy/48.png"]`) + ).toBeTruthy() + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [ + { term: assetCreatorLabel, description: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE' }, + { term: assetManagerLabel, description: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE' }, + { term: assetReserveLabel, description: '6ZTNQ3SPQEYOWIXZHQR6HSX6CZSQ4FLYOXOCPNJSNRRT6QA2FFD6JIBDSI' }, + ], + }) + + const assetMetadataCard = component.getByText(assetMetadataLabel).parentElement! + descriptionListAssertion({ + container: assetMetadataCard, + items: [ + { term: 'Name', description: 'Coop #48' }, + { term: 'Standard', description: 'arc3' }, + { term: 'Image', description: 'ipfs://bafybeigx4jqvsvkxdflvwvr2bmurrlugv4ulbgw7juhkd3rz52w32enwoy/48.png' }, + { term: 'Image Mime Type', description: 'image/png' }, + { + term: 'Description', + description: + "A troop of 890 Coopers based on Cooper Daniels' DEV'N and his quest for CoopCoin Beach. Artwork by F.o.E. and inspired by the original work of Blockrunner for the ReCoop Show.", + }, + { term: 'Extra', description: '{}' }, + ], + }) + + const assetTraitsCard = component.getByText(assetTraitsLabel).parentElement! + descriptionListAssertion({ + container: assetTraitsCard, + items: [ + { term: 'Background', description: 'Soft Cream' }, + { term: 'Base', description: 'Coop v1' }, + { term: 'Tat', description: 'Naked' }, + { term: 'Chest Hair', description: 'Clean' }, + { term: 'Outfit', description: 'Coop Hoodie' }, + { term: 'Face Tat', description: 'Clean' }, + { term: 'Face Trait', description: 'Gold Grill' }, + { term: 'Lower Face', description: 'Fresh Trim' }, + { term: 'Upper Head', description: 'Scoopy' }, + { term: 'Eyes', description: 'Coop Brokelys' }, + { term: 'Ears', description: 'Hoop' }, + ], + }) + }) + } + ) + }) + }) + + describe('when rendering a deleted asset', () => { + const assetResult = assetResultMother['mainnet-917559']().build() + const createAssetTransactionResult = transactionResultMother['mainnet-A5MOSCZBJAENBFJ5WDEYYXTTXQAADS6EQFHYLPTHS5WMQ7ZGSM2Q']().build() + const reconfigureAssetTransactionResult = + transactionResultMother['mainnet-HTGK2WBVXTOHV7X5ER3QT3JH2NQSZU43KEMSTHXMJO5D2E3ROT6Q']().build() + const destroyAssetTransactionResult = transactionResultMother['mainnet-U4XH6AS5UUYQI4IZ3E5JSUEIU64Y3FGNYKLH26W4HRY7T6PK745A']().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[assetResult.index, assetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: assetResult.index.toString() })) + vi.mocked(indexer.searchForTransactions().assetID(assetResult.index).txType('acfg').do).mockImplementation(() => + Promise.resolve([createAssetTransactionResult, reconfigureAssetTransactionResult, destroyAssetTransactionResult]) + ) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '917559Deleted' }, + { term: assetTotalSupplyLabel, description: '0 ' }, + { term: assetDecimalsLabel, description: '0' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + ], + }) + + const assetAddressesCard = component.getByText(assetAddressesLabel).parentElement! + descriptionListAssertion({ + container: assetAddressesCard, + items: [{ term: assetCreatorLabel, description: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY' }], + }) + }) + } + ) + }) + }) + + describe('when rendering the algo asset', () => { + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(assetResultsAtom, new Map([[algoAssetResult.index, algoAssetResult]])) + + vi.mocked(useParams).mockImplementation(() => ({ assetId: algoAssetResult.index.toString() })) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(() => { + const detailsCard = component.getByLabelText(assetDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: assetIdLabel, description: '0Fungible' }, + { term: assetNameLabel, description: 'ALGO' }, + { term: assetUnitLabel, description: 'ALGO' }, + { term: assetTotalSupplyLabel, description: '10000000000 ALGO' }, + { term: assetDecimalsLabel, description: '6' }, + { term: assetDefaultFrozenLabel, description: 'No' }, + { term: assetUrlLabel, description: 'https://www.algorand.foundation' }, + ], + }) + + const assetAddressesCard = component.queryByText(assetAddressesLabel) + expect(assetAddressesCard).toBeNull() + + const assetMetadataCard = component.queryByText(assetMetadataLabel) + expect(assetMetadataCard).toBeNull() + + const assetTraitsCard = component.queryByText(assetTraitsLabel) + expect(assetTraitsCard).toBeNull() + }) + } + ) + }) + }) +}) diff --git a/src/features/assets/pages/asset-page.tsx b/src/features/assets/pages/asset-page.tsx index e03c80fbd..5ff5e79bc 100644 --- a/src/features/assets/pages/asset-page.tsx +++ b/src/features/assets/pages/asset-page.tsx @@ -3,10 +3,10 @@ import { UrlParams } from '../../../routes/urls' import { useRequiredParam } from '../../common/hooks/use-required-param' import { cn } from '@/features/common/utils' import { isInteger } from '@/utils/is-integer' -import { useLoadableAsset } from '../data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { is404 } from '@/utils/error' import { AssetDetails } from '../components/asset-details' +import { useLoadableAsset } from '../data' const transformError = (e: Error) => { if (is404(e)) { diff --git a/src/features/assets/utils/arc16.ts b/src/features/assets/utils/arc16.ts new file mode 100644 index 000000000..44b4e819a --- /dev/null +++ b/src/features/assets/utils/arc16.ts @@ -0,0 +1,2 @@ +// When properties contains the key traits, it conforms to ARC-16. +export const isArc16Properties = (properties: Record) => 'traits' in properties diff --git a/src/features/assets/utils/arc19.ts b/src/features/assets/utils/arc19.ts new file mode 100644 index 000000000..f82a5c51c --- /dev/null +++ b/src/features/assets/utils/arc19.ts @@ -0,0 +1,49 @@ +import { CID, Version } from 'multiformats/cid' +import * as digest from 'multiformats/hashes/digest' +import { sha256 } from 'multiformats/hashes/sha2' +import algosdk from 'algosdk' +import { replaceIpfsWithGatewayIfNeeded } from './replace-ipfs-with-gateway-if-needed' + +// If the URL starts with template-ipfs://, it also follows ARC-19 +export const isArc19Url = (assetUrl: string) => assetUrl.startsWith('template-ipfs://') + +export function getArc19Url(templateUrl: string, reserveAddress: string | undefined): string | undefined { + // https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0019.md + const ipfsTemplateUrlMatch = new RegExp( + /^template-ipfs:\/\/{ipfscid:(?[01]):(?[a-z0-9-]+):(?[a-z0-9-]+):(?[a-z0-9-]+)}/ + ) + + const match = ipfsTemplateUrlMatch.exec(templateUrl) + if (!match) { + if (templateUrl.startsWith('template-ipfs://')) { + throw new Error(`Invalid ASA URL; unable to parse as IPFS template URL '${templateUrl}'`) + } + return undefined + } + + const { field, codec, hash, version } = match?.groups ?? {} + + if (field !== 'reserve') { + throw new Error(`Invalid ASA URL; unsupported field '${field}' (expected 'reserve') in IPFS template URL '${templateUrl}'`) + } + if (codec !== 'raw' && codec !== 'dag-pb') { + throw new Error(`Invalid ASA URL; unsupported codec '${codec}' (expected 'raw' or 'dag-pb') in IPFS template URL '${templateUrl}'`) + } + if (hash !== 'sha2-256') { + throw new Error(`Invalid ASA URL; unsupported hash '${hash}' (expected 'sha2-256') in IPFS template URL '${templateUrl}'`) + } + if (version != '0' && version != '1') { + throw new Error(`Invalid ASA URL; unsupported version '${version}' (expected '0' or '1') in IPFS template URL '${templateUrl}'`) + } + + const hashAlgorithm = sha256 + const publicKey = algosdk.decodeAddress(reserveAddress!).publicKey + const multihashDigest = digest.create(hashAlgorithm.code, publicKey) + + // https://github.com/TxnLab/arc3.xyz/blob/66334cb31cf46a3b0a466193f351d766df24a16c/src/lib/nft.ts#L68 + const cid = CID.create(parseInt(version) as Version, match?.groups?.codec === 'dag-pb' ? 0x70 : 0x55, multihashDigest) + + const url = templateUrl.replace(match[0], `ipfs://${cid.toString()}`).replace(/#arc3$/, '') + + return replaceIpfsWithGatewayIfNeeded(url) +} diff --git a/src/features/assets/utils/arc3.ts b/src/features/assets/utils/arc3.ts new file mode 100644 index 000000000..a423050f0 --- /dev/null +++ b/src/features/assets/utils/arc3.ts @@ -0,0 +1,9 @@ +import { AssetIndex } from '../data/types' +import { replaceIpfsWithGatewayIfNeeded } from './replace-ipfs-with-gateway-if-needed' + +// When the URL contains #arc3 or @arc3, it follows ARC-3 +export const isArc3Url = (assetUrl: string) => assetUrl.includes('#arc3') || assetUrl.includes('@arc3') + +export const getArc3Url = (assetIndex: AssetIndex, url: string): string => { + return replaceIpfsWithGatewayIfNeeded(url.replace('{id}', assetIndex.toString())) +} diff --git a/src/features/assets/utils/replace-ipfs-with-gateway-if-needed.ts b/src/features/assets/utils/replace-ipfs-with-gateway-if-needed.ts new file mode 100644 index 000000000..5f96fb663 --- /dev/null +++ b/src/features/assets/utils/replace-ipfs-with-gateway-if-needed.ts @@ -0,0 +1,5 @@ +export const ipfsGatewayUrl = 'https://ipfs.algonode.xyz/ipfs/' + +export const replaceIpfsWithGatewayIfNeeded = (url: string): string => { + return url.replace('ipfs://', ipfsGatewayUrl) +} diff --git a/src/features/blocks/data/block.ts b/src/features/blocks/data/block.ts index f566c00a3..7d5de07bd 100644 --- a/src/features/blocks/data/block.ts +++ b/src/features/blocks/data/block.ts @@ -66,24 +66,28 @@ export const syncBlockAtomEffectBuilder = (fetchBlockResultAtom: ReturnType 0) { set(transactionResultsAtom, (prev) => { + const next = new Map(prev) transactionResults.forEach((t) => { - prev.set(t.id, t) + next.set(t.id, t) }) - return prev + return next }) } if (groupResults.size > 0) { set(groupResultsAtom, (prev) => { + const next = new Map(prev) groupResults.forEach((g) => { - prev.set(g.id, g) + next.set(g.id, g) }) - return prev + return next }) } set(blockResultsAtom, (prev) => { - return prev.set(blockResult.round, blockResult) + const next = new Map(prev) + next.set(blockResult.round, blockResult) + return next }) } catch (e) { // Ignore any errors as there is nothing to sync diff --git a/src/features/blocks/data/latest-blocks.ts b/src/features/blocks/data/latest-blocks.ts index 2f236e2ab..76c4830e5 100644 --- a/src/features/blocks/data/latest-blocks.ts +++ b/src/features/blocks/data/latest-blocks.ts @@ -12,6 +12,10 @@ import { TransactionId } from '@/features/transactions/data/types' import { TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer' import { blockResultsAtom, syncedRoundAtom } from './core' import { BlockResult, Round } from './types' +import { assetMetadataAtom, assetResultsAtom } from '@/features/assets/data/core' +import algosdk from 'algosdk' +import { flattenTransactionResult } from '@/features/transactions/utils/flatten-transaction-result' +import { distinct } from '@/utils/distinct' const maxBlocksToDisplay = 5 @@ -106,17 +110,47 @@ const subscribeToBlocksEffect = atomEffect((get, set) => { }) set(transactionResultsAtom, (prev) => { + const next = new Map(prev) transactions.forEach((value, key) => { - prev.set(key, value) + next.set(key, value) + }) + return next + }) + + transactions.forEach((t) => { + const affectedAssetIds = flattenTransactionResult(t) + .filter((t) => t['tx-type'] === algosdk.TransactionType.acfg) + .map((t) => t['asset-config-transaction']!['asset-id']) + .filter(distinct((x) => x)) + .filter(isDefined) // We ignore asset create transactions because they aren't in the atom + + affectedAssetIds.forEach(async (assetId) => { + // Invalidate any asset caches that are potentially stale because of this transaction + + if (get.peek(assetMetadataAtom).has(assetId)) { + set(assetMetadataAtom, (prev) => { + const next = new Map(prev) + next.delete(assetId) + return next + }) + } + + if (get.peek(assetResultsAtom).has(assetId)) { + set(assetResultsAtom, (prev) => { + const next = new Map(prev) + next.delete(assetId) + return next + }) + } }) - return prev }) set(blockResultsAtom, (prev) => { + const next = new Map(prev) blocks.forEach(([key, value]) => { - prev.set(key, value) + next.set(key, value) }) - return prev + return next }) }) diff --git a/src/features/common/components/display-asset-amount.tsx b/src/features/common/components/display-asset-amount.tsx index dd330d6aa..a8acf5533 100644 --- a/src/features/common/components/display-asset-amount.tsx +++ b/src/features/common/components/display-asset-amount.tsx @@ -1,10 +1,10 @@ -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' import { cn } from '../utils' import Decimal from 'decimal.js' type Props = { amount: number | bigint - asset: Asset + asset: AssetSummary className?: string } diff --git a/src/features/search/data/search.ts b/src/features/search/data/search.ts index 9384c6a60..b59d07ad9 100644 --- a/src/features/search/data/search.ts +++ b/src/features/search/data/search.ts @@ -6,7 +6,7 @@ import { JotaiStore } from '@/features/common/data/types' import { Urls } from '@/routes/urls' import { is404 } from '@/utils/error' import { getApplicationAtomBuilder } from '@/features/applications/data' -import { getAssetAtomBuilder } from '@/features/assets/data' +import { getAssetSummaryAtomBuilder } from '@/features/assets/data' import { SearchResult, SearchResultType } from '../models' import { ellipseAddress } from '@/utils/ellipse-address' import { ellipseId } from '@/utils/ellipse-id' @@ -65,7 +65,7 @@ const getSearchAtomsBuilder = (store: JotaiStore) => { }) } - const assetAtom = getAssetAtomBuilder(store, id) + const assetAtom = getAssetSummaryAtomBuilder(store, id) const applicationAtom = getApplicationAtomBuilder(store, id) try { @@ -78,7 +78,7 @@ const getSearchAtomsBuilder = (store: JotaiStore) => { results.push({ type: SearchResultType.Asset, id: id, - label: `${id} (${asset.name})`, + label: asset.name ? `${id} (${asset.name})` : id.toString(), url: Urls.Explore.Asset.ById.build({ assetId: id.toString() }), }) } diff --git a/src/features/transactions/components/transactions-graph.test.tsx b/src/features/transactions/components/transactions-graph.test.tsx index 8adf9e979..e3391d105 100644 --- a/src/features/transactions/components/transactions-graph.test.tsx +++ b/src/features/transactions/components/transactions-graph.test.tsx @@ -6,7 +6,7 @@ import { asAppCallTransaction, asAssetTransferTransaction, asPaymentTransaction, import { AssetResult, TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer' import { assetResultMother } from '@/tests/object-mother/asset-result' import { useParams } from 'react-router-dom' -import { asAsset } from '@/features/assets/mappers' +import { asAssetSummary } from '@/features/assets/mappers/asset-summary' import { TransactionsGraph } from './transactions-graph' import { asKeyRegTransaction } from '../mappers/key-reg-transaction-mappers' import { asGroup } from '@/features/groups/mappers' @@ -59,7 +59,7 @@ describe('asset-transfer-transaction-graph', () => { 'when rendering transaction $transactionResult.id', ({ transactionResult, assetResult }: { transactionResult: TransactionResult; assetResult: AssetResult }) => { it('should match snapshot', () => { - const transaction = asAssetTransferTransaction(transactionResult, asAsset(assetResult)) + const transaction = asAssetTransferTransaction(transactionResult, asAssetSummary(assetResult)) return executeComponentTest( () => render(), @@ -94,7 +94,7 @@ describe('application-call-graph', () => { it('should match snapshot', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transactionResult.id })) - const model = asAppCallTransaction(transactionResult, assetResults.map(asAsset)) + const model = asAppCallTransaction(transactionResult, assetResults.map(asAssetSummary)) return executeComponentTest( () => render(), @@ -166,7 +166,7 @@ describe('group-graph', () => { if (!assetResult) { throw new Error(`Could not find asset result ${assetId}`) } - return asAsset(assetResult) + return asAssetSummary(assetResult) }) ) ) diff --git a/src/features/transactions/data/inner-transaction.ts b/src/features/transactions/data/inner-transaction.ts index 60e57af39..5f79d33e3 100644 --- a/src/features/transactions/data/inner-transaction.ts +++ b/src/features/transactions/data/inner-transaction.ts @@ -5,7 +5,7 @@ import { loadable } from 'jotai/utils' import { TransactionId } from './types' import { JotaiStore } from '@/features/common/data/types' import { asTransaction } from '../mappers/transaction-mappers' -import { getAssetAtomBuilder } from '@/features/assets/data' +import { getAssetSummaryAtomBuilder } from '@/features/assets/data' import { InnerTransaction, Transaction, TransactionType } from '../models' import { fetchTransactionResultAtomBuilder } from './transaction' @@ -16,7 +16,7 @@ export const fetchInnerTransactionAtomBuilder = ( ) => { return atom(async (get) => { const txn = 'id' in transactionResult ? transactionResult : await get(transactionResult) - const transaction = await asTransaction(txn, (assetId: number) => get(getAssetAtomBuilder(store, assetId))) + const transaction = await asTransaction(txn, (assetId: number) => get(getAssetSummaryAtomBuilder(store, assetId))) if (transaction.type !== TransactionType.ApplicationCall) { throw new Error('Only application call transactions have inner transactions') } diff --git a/src/features/transactions/data/transaction.ts b/src/features/transactions/data/transaction.ts index 1a9953ee1..badb4c3bd 100644 --- a/src/features/transactions/data/transaction.ts +++ b/src/features/transactions/data/transaction.ts @@ -10,7 +10,7 @@ import { TransactionId } from './types' import { indexer } from '@/features/common/data' import { JotaiStore } from '@/features/common/data/types' import { asTransaction } from '../mappers/transaction-mappers' -import { getAssetAtomBuilder, getAssetsAtomBuilder } from '@/features/assets/data' +import { getAssetSummaryAtomBuilder, getAssetSummariesAtomBuilder } from '@/features/assets/data' import { getAssetIdsForTransaction } from '../utils/get-asset-ids-for-transaction' import { transactionResultsAtom } from './core' @@ -20,7 +20,9 @@ export const fetchTransactionResultAtomBuilder = (store: JotaiStore, transaction try { const transactionResult = await get(transactionResultAtom) set(transactionResultsAtom, (prev) => { - return new Map([...prev, [transactionResult.id, transactionResult]]) + const next = new Map(prev) + next.set(transactionResult.id, transactionResult) + return next }) } catch (e) { // Ignore any errors as there is nothing to sync @@ -82,7 +84,7 @@ export const fetchTransactionsAtomBuilder = ( ) const assets = new Map( - (await get(getAssetsAtomBuilder(store, assetIds))).map((a) => { + (await get(getAssetSummariesAtomBuilder(store, assetIds))).map((a) => { return [a.id, a] as const }) ) @@ -105,7 +107,7 @@ export const fetchTransactionAtomBuilder = ( ) => { return atom(async (get) => { const txn = 'id' in transactionResult ? transactionResult : await get(transactionResult) - return await asTransaction(txn, (assetId: number) => get(getAssetAtomBuilder(store, assetId))) + return await asTransaction(txn, (assetId: number) => get(getAssetSummaryAtomBuilder(store, assetId))) }) } diff --git a/src/features/transactions/mappers/app-call-transaction-mappers.ts b/src/features/transactions/mappers/app-call-transaction-mappers.ts index 909bac3f1..7af24872b 100644 --- a/src/features/transactions/mappers/app-call-transaction-mappers.ts +++ b/src/features/transactions/mappers/app-call-transaction-mappers.ts @@ -6,7 +6,7 @@ import { mapCommonTransactionProperties, asInnerTransactionId } from './transact import { TransactionType as AlgoSdkTransactionType } from 'algosdk' import { asInnerPaymentTransaction } from './payment-transaction-mappers' import { asInnerAssetTransferTransaction } from './asset-transfer-transaction-mappers' -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' import { asInnerAssetConfigTransaction } from './asset-config-transaction-mappers' import { asInnerAssetFreezeTransaction } from './asset-freeze-transaction-mappers' import { asInnerKeyRegTransaction } from './key-reg-transaction-mappers' @@ -14,7 +14,7 @@ import { asInnerKeyRegTransaction } from './key-reg-transaction-mappers' const mapCommonAppCallTransactionProperties = ( networkTransactionId: string, transactionResult: TransactionResult, - assets: Asset[], + assets: AssetSummary[], indexPrefix?: string ) => { invariant(transactionResult['application-transaction'], 'application-transaction is not set') @@ -41,7 +41,7 @@ const mapCommonAppCallTransactionProperties = ( } satisfies BaseAppCallTransaction } -export const asAppCallTransaction = (transactionResult: TransactionResult, assets: Asset[]): AppCallTransaction => { +export const asAppCallTransaction = (transactionResult: TransactionResult, assets: AssetSummary[]): AppCallTransaction => { const commonProperties = mapCommonAppCallTransactionProperties(transactionResult.id, transactionResult, assets) return { @@ -54,7 +54,7 @@ export const asInnerAppCallTransaction = ( networkTransactionId: string, index: string, transactionResult: TransactionResult, - assets: Asset[] + assets: AssetSummary[] ): InnerAppCallTransaction => { return { ...asInnerTransactionId(networkTransactionId, index), @@ -79,7 +79,7 @@ const asAppCallOnComplete = (indexerEnum: ApplicationOnComplete): AppCallOnCompl } } -const asInnerTransaction = (networkTransactionId: string, index: string, transactionResult: TransactionResult, assets: Asset[]) => { +const asInnerTransaction = (networkTransactionId: string, index: string, transactionResult: TransactionResult, assets: AssetSummary[]) => { if (transactionResult['tx-type'] === AlgoSdkTransactionType.pay) { return asInnerPaymentTransaction(networkTransactionId, index, transactionResult) } diff --git a/src/features/transactions/mappers/asset-freeze-transaction-mappers.ts b/src/features/transactions/mappers/asset-freeze-transaction-mappers.ts index fe54b4631..b68812e60 100644 --- a/src/features/transactions/mappers/asset-freeze-transaction-mappers.ts +++ b/src/features/transactions/mappers/asset-freeze-transaction-mappers.ts @@ -8,9 +8,12 @@ import { } from '../models' import { invariant } from '@/utils/invariant' import { asInnerTransactionId, mapCommonTransactionProperties } from './transaction-common-properties-mappers' -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' -const mapCommonAssetFreezeTransactionProperties = (transactionResult: TransactionResult, asset: Asset): BaseAssetFreezeTransaction => { +const mapCommonAssetFreezeTransactionProperties = ( + transactionResult: TransactionResult, + asset: AssetSummary +): BaseAssetFreezeTransaction => { invariant(transactionResult['asset-freeze-transaction'], 'asset-freeze-transaction is not set') return { @@ -25,7 +28,7 @@ const mapCommonAssetFreezeTransactionProperties = (transactionResult: Transactio } } -export const asAssetFreezeTransaction = (transactionResult: TransactionResult, asset: Asset): AssetFreezeTransaction => { +export const asAssetFreezeTransaction = (transactionResult: TransactionResult, asset: AssetSummary): AssetFreezeTransaction => { return { id: transactionResult.id, ...mapCommonAssetFreezeTransactionProperties(transactionResult, asset), @@ -36,7 +39,7 @@ export const asInnerAssetFreezeTransaction = ( networkTransactionId: string, index: string, transactionResult: TransactionResult, - asset: Asset + asset: AssetSummary ): InnerAssetFreezeTransaction => { return { ...asInnerTransactionId(networkTransactionId, index), diff --git a/src/features/transactions/mappers/asset-transfer-transaction-mappers.ts b/src/features/transactions/mappers/asset-transfer-transaction-mappers.ts index 8a0168c23..b26915f41 100644 --- a/src/features/transactions/mappers/asset-transfer-transaction-mappers.ts +++ b/src/features/transactions/mappers/asset-transfer-transaction-mappers.ts @@ -9,9 +9,9 @@ import { import { invariant } from '@/utils/invariant' import { ZERO_ADDRESS } from '@/features/common/constants' import { asInnerTransactionId, mapCommonTransactionProperties } from './transaction-common-properties-mappers' -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' -const mapCommonAssetTransferTransactionProperties = (transactionResult: TransactionResult, asset: Asset) => { +const mapCommonAssetTransferTransactionProperties = (transactionResult: TransactionResult, asset: AssetSummary) => { invariant(transactionResult['asset-transfer-transaction'], 'asset-transfer-transaction is not set') const subType = () => { @@ -54,7 +54,7 @@ const mapCommonAssetTransferTransactionProperties = (transactionResult: Transact } satisfies BaseAssetTransferTransaction } -export const asAssetTransferTransaction = (transactionResult: TransactionResult, asset: Asset): AssetTransferTransaction => { +export const asAssetTransferTransaction = (transactionResult: TransactionResult, asset: AssetSummary): AssetTransferTransaction => { return { id: transactionResult.id, ...mapCommonAssetTransferTransactionProperties(transactionResult, asset), @@ -65,7 +65,7 @@ export const asInnerAssetTransferTransaction = ( networkTransactionId: string, index: string, transactionResult: TransactionResult, - asset: Asset + asset: AssetSummary ): InnerAssetTransferTransaction => { return { ...asInnerTransactionId(networkTransactionId, index), diff --git a/src/features/transactions/mappers/transaction-mappers.ts b/src/features/transactions/mappers/transaction-mappers.ts index 48de9ae80..a0478c7e0 100644 --- a/src/features/transactions/mappers/transaction-mappers.ts +++ b/src/features/transactions/mappers/transaction-mappers.ts @@ -5,14 +5,17 @@ import algosdk from 'algosdk' import { asAppCallTransaction } from './app-call-transaction-mappers' import { asAssetTransferTransaction } from './asset-transfer-transaction-mappers' import { asPaymentTransaction } from './payment-transaction-mappers' -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' import { getAssetIdsForTransaction } from '../utils/get-asset-ids-for-transaction' import { asAssetConfigTransaction } from './asset-config-transaction-mappers' import { asAssetFreezeTransaction } from './asset-freeze-transaction-mappers' import { asStateProofTransaction } from './state-proof-transaction-mappers' import { asKeyRegTransaction } from './key-reg-transaction-mappers' -export const asTransaction = async (transactionResult: TransactionResult, assetResolver: (assetId: number) => Promise | Asset) => { +export const asTransaction = async ( + transactionResult: TransactionResult, + assetResolver: (assetId: number) => Promise | AssetSummary +) => { switch (transactionResult['tx-type']) { case algosdk.TransactionType.pay: return asPaymentTransaction(transactionResult) diff --git a/src/features/transactions/models/index.ts b/src/features/transactions/models/index.ts index 4c99aee95..feecdb496 100644 --- a/src/features/transactions/models/index.ts +++ b/src/features/transactions/models/index.ts @@ -1,4 +1,4 @@ -import { Asset } from '@/features/assets/models' +import { AssetSummary } from '@/features/assets/models' import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' type Address = string @@ -59,7 +59,7 @@ export type BaseAssetTransferTransaction = CommonTransactionProperties & { receiver: Address amount: number | bigint closeRemainder?: CloseAssetRemainder - asset: Asset + asset: AssetSummary clawbackFrom?: Address } diff --git a/src/features/transactions/pages/inner-transaction-page.tsx b/src/features/transactions/pages/inner-transaction-page.tsx index c5aba6857..addeff732 100644 --- a/src/features/transactions/pages/inner-transaction-page.tsx +++ b/src/features/transactions/pages/inner-transaction-page.tsx @@ -7,9 +7,10 @@ import { RenderLoadable } from '@/features/common/components/render-loadable' import { cn } from '@/features/common/utils' import { isValidInnerTransactionId } from '../utils/is-valid-inner-transaction-id' import { isTransactionId } from '@/utils/is-transaction-id' +import { is404 } from '@/utils/error' const transformError = (e: Error) => { - if ('status' in e && e.status === 404) { + if (is404(e)) { return new Error(transactionNotFoundMessage) } diff --git a/src/features/transactions/utils/flatten-transaction-result.ts b/src/features/transactions/utils/flatten-transaction-result.ts new file mode 100644 index 000000000..d99643d96 --- /dev/null +++ b/src/features/transactions/utils/flatten-transaction-result.ts @@ -0,0 +1,14 @@ +import { TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer' +import algosdk from 'algosdk' + +export const flattenTransactionResult = (transactionResult: TransactionResult): TransactionResult[] => { + const results = [transactionResult] + + if (transactionResult['tx-type'] !== algosdk.TransactionType.appl) { + return results + } + + const innerTransactions = transactionResult['inner-txns'] ?? [] + + return results.concat(innerTransactions.flatMap(flattenTransactionResult)) +} diff --git a/src/tests/object-mother/asset-result.ts b/src/tests/object-mother/asset-result.ts index a54563027..0b367050b 100644 --- a/src/tests/object-mother/asset-result.ts +++ b/src/tests/object-mother/asset-result.ts @@ -177,4 +177,134 @@ export const assetResultMother = { }, } satisfies AssetResult) }, + 'mainnet-1284444444': () => { + return new AssetResultBuilder({ + 'created-at-round': 34632901, + deleted: false, + index: 1284444444, + params: { + clawback: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + creator: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI', + decimals: 8, + 'default-frozen': false, + freeze: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + manager: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI', + 'metadata-hash': encoder.encode('0/1Rvi7owrF6eugm00nA3yD+q4pNaAMDQBx0FWDDJDY='), + name: 'Orange', + 'name-b64': encoder.encode('T3Jhbmdl'), + reserve: 'JP3ENKDQC2BOYRMLFGKBS7RB2IVNF7VNHCFHVTRNHOENRQ6R4UN7MCNXPI', + total: 400000000000000, + 'unit-name': 'ORA', + 'unit-name-b64': encoder.encode('T1JB'), + url: 'ipfs://QmUitxJuPJJrcuAdAiVdEEpuzGmsELGgAvhLd5FiXRShEu#arc3', + 'url-b64': encoder.encode('aXBmczovL1FtVWl0eEp1UEpKcmN1QWRBaVZkRUVwdXpHbXNFTEdnQXZoTGQ1RmlYUlNoRXUjYXJjMw=='), + }, + }) + }, + 'mainnet-1494117806': () => { + return new AssetResultBuilder({ + 'created-at-round': 36012728, + deleted: false, + index: 1494117806, + params: { + clawback: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + creator: 'UF5DSSCT3GO62CSTSFB4QN5GNKFIMO7HCF2OIY6D57Z37IETEXRKUUNOPU', + decimals: 0, + 'default-frozen': false, + freeze: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + manager: 'UF5DSSCT3GO62CSTSFB4QN5GNKFIMO7HCF2OIY6D57Z37IETEXRKUUNOPU', + name: 'Zappy #1620', + 'name-b64': encoder.encode('WmFwcHkgIzE2MjA='), + reserve: 'OPL3M2ZOKLSPVIM32MRK45O6IQMHTJPVWOWPVTEGXVHC3GHFLJK2YC5OWE', + total: 1, + 'unit-name': 'ZAPP1620', + 'unit-name-b64': encoder.encode('WkFQUDE2MjA='), + url: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}#arc3', + 'url-b64': encoder.encode('dGVtcGxhdGUtaXBmczovL3tpcGZzY2lkOjE6cmF3OnJlc2VydmU6c2hhMi0yNTZ9I2FyYzM='), + }, + }) + }, + 'mainnet-1800979729': () => { + return new AssetResultBuilder({ + 'created-at-round': 38393946, + deleted: false, + index: 1800979729, + params: { + clawback: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + creator: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + decimals: 0, + 'default-frozen': false, + freeze: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + manager: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + name: 'DHMα: M1 Solar Flare SCQCSO', + 'name-b64': encoder.encode('REhNzrE6IE0xIFNvbGFyIEZsYXJlIFNDUUNTTw=='), + reserve: 'ESK3ZHVALWTRWTEQVRO4ZGZGGOFCKCJNVE5ODFMPWICXVSJVJZYINHHYHE', + total: 1, + 'unit-name': 'SOLFLARE', + 'unit-name-b64': encoder.encode('U09MRkxBUkU='), + url: 'https://assets.datahistory.org/solar/SCQCSO.mp4#v', + 'url-b64': encoder.encode('aHR0cHM6Ly9hc3NldHMuZGF0YWhpc3Rvcnkub3JnL3NvbGFyL1NDUUNTTy5tcDQjdg=='), + }, + }) + }, + 'mainnet-854081201': () => { + return new AssetResultBuilder({ + 'created-at-round': 23110800, + deleted: false, + index: 854081201, + params: { + clawback: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + creator: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE', + decimals: 0, + 'default-frozen': false, + freeze: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + manager: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE', + name: 'Bad Bunny Society #587', + 'name-b64': encoder.encode('QmFkIEJ1bm55IFNvY2lldHkgIzU4Nw=='), + reserve: 'V4UCC2YXBHLELD7Y6HYSKZI4GABLUG5HE6HAQQ36OBXFEZS7W4VMWB6DUQ', + total: 1, + 'unit-name': 'bbs587', + 'unit-name-b64': encoder.encode('YmJzNTg3'), + url: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}', + 'url-b64': encoder.encode('dGVtcGxhdGUtaXBmczovL3tpcGZzY2lkOjE6cmF3OnJlc2VydmU6c2hhMi0yNTZ9'), + }, + }) + }, + 'mainnet-917559': () => { + return new AssetResultBuilder({ + 'created-at-round': 6354271, + deleted: true, + 'destroyed-at-round': 6354625, + index: 917559, + params: { + clawback: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + creator: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + decimals: 0, + 'default-frozen': false, + freeze: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + manager: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + reserve: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5HFKQ', + total: 0, + }, + }) + }, + 'mainnet-1820067164': () => { + return new AssetResultBuilder({ + index: 1820067164, + params: { + creator: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE', + decimals: 0, + 'default-frozen': false, + manager: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE', + name: 'Coop #48', + 'name-b64': encoder.encode('Q29vcCAjNDg='), + reserve: '6ZTNQ3SPQEYOWIXZHQR6HSX6CZSQ4FLYOXOCPNJSNRRT6QA2FFD6JIBDSI', + total: 1, + 'unit-name': 'Coop48', + 'unit-name-b64': encoder.encode('Q29vcDQ4'), + url: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}', + 'url-b64': encoder.encode('dGVtcGxhdGUtaXBmczovL3tpcGZzY2lkOjE6cmF3OnJlc2VydmU6c2hhMi0yNTZ9'), + }, + }) + }, } diff --git a/src/tests/object-mother/transaction-result.ts b/src/tests/object-mother/transaction-result.ts index a8571d2be..fb593d275 100644 --- a/src/tests/object-mother/transaction-result.ts +++ b/src/tests/object-mother/transaction-result.ts @@ -48,6 +48,21 @@ export const transactionResultMother = { logicsig: { logic: 'CIEBQw==' }, }) }, + assetConfig: () => { + return transactionResultBuilder() + ['withTx-type'](TransactionType.acfg) + ['withAsset-config-transaction']({ + 'asset-id': 1234, + params: { + creator: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + decimals: 0, + 'default-frozen': false, + manager: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + reserve: 'POMY37RQ5PYG2NHKEFVDVDKGWZLZ4NHUWUW57CVGZVIPZCTNAFE2JM7XQU', + total: 0, + }, + }) + }, ['mainnet-FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ']: () => { return new TransactionResultBuilder({ id: 'FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ', @@ -791,7 +806,7 @@ export const transactionResultMother = { } as unknown as TransactionResult) // The type definition for App Call transaction in indexer seems to be wrong }, ['mainnet-U4XH6AS5UUYQI4IZ3E5JSUEIU64Y3FGNYKLH26W4HRY7T6PK745A']: () => { - // Asset config destroy asset + // Asset config destroy asset for 917559 return new TransactionResultBuilder({ 'asset-config-transaction': { 'asset-id': 917559, @@ -1074,4 +1089,196 @@ export const transactionResultMother = { 'tx-type': TransactionType.appl, } as unknown as TransactionResult) // The type definition for App Call transaction in indexer seems to be wrong }, + ['mainnet-4BFQTYKSJNRF52LXCMBXKDWLODRDVGSUCW36ND3B7C3ZQKPMLUJA']: () => { + // Asset config + return new TransactionResultBuilder({ + 'asset-config-transaction': { + 'asset-id': 1800979729, + params: { + creator: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + decimals: 0, + 'default-frozen': false, + manager: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + reserve: 'ESK3ZHVALWTRWTEQVRO4ZGZGGOFCKCJNVE5ODFMPWICXVSJVJZYINHHYHE', + total: 0, + }, + }, + 'auth-addr': '5ROHDU55YSX545QRE5G2SGZD77OVAJK4RKHAAT7CPMHMNRJACSQUKTUT3M', + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 38394154, + fee: 1000, + 'first-valid': 38394151, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + 'genesis-id': 'mainnet-v1.0', + id: '4BFQTYKSJNRF52LXCMBXKDWLODRDVGSUCW36ND3B7C3ZQKPMLUJA', + 'intra-round-offset': 3, + 'last-valid': 38395151, + note: 'eyJzdGFuZGFyZCI6ImFyYzY5IiwiZGVzY3JpcHRpb24iOiJUaGlzIGlzIGFuIGFscGhhIGRhdGEgYXJ0aWZhY3QgbWludGVkIGJ5IFRoZSBEYXRhIEhpc3RvcnkgTXVzZXVtLiBJdCByZXByZXNlbnRzIGEgQ2xhc3MgTTEuNiBzb2xhciBmbGFyZS4gVGhlIHZlcmlmaWVkIHNvdXJjZSBvZiB0aGlzIGRhdGEgYXJ0aWZhY3Qgd2FzIHRoZSBOYXRpb25hbCBPY2VhbmljIGFuZCBBdG1vc3BoZXJpYyBBZG1pbmlzdHJhdGlvbiAoTk9BQSkuIEZvciBtb3JlIGluZm9ybWF0aW9uIHZpc2l0IGh0dHBzOi8vZGF0YWhpc3Rvcnkub3JnLy4iLCJleHRlcm5hbF91cmwiOiJodHRwczovL211c2V1bS5kYXRhaGlzdG9yeS5vcmcvZXZlbnQvU09MRkxBUkUvU0NRQ1NPIiwicHJvcGVydGllcyI6eyJzYXRlbGxpdGUiOiJHT0VTLTE2Iiwic291cmNlIjoiTk9BQSIsImJlZ2luVGltZSI6IjIwMjQtMDQtMzBUMDA6NDY6MDBaIiwiYmVnaW5DbGFzcyI6IkMxLjEiLCJwZWFrQ2xhc3MiOiJNMS42IiwicGVha1RpbWUiOiIyMDI0LTA0LTMwVDAxOjE0OjAwWiIsInBlYWtYcmF5Rmx1eCI6IjEuNjQxMTBlLTUgV23igbvCsiIsImVuZFRpbWUiOiIyMDI0LTA0LTMwVDAxOjMxOjAwWiIsImVuZENsYXNzIjoiQzguMyIsInR5cGUiOiJzb2xhciIsInN1YlR5cGUiOiJmbGFyZSJ9LCJtaW1lX3R5cGUiOiJ2aWRlby9tcDQiLCJpZCI6IlNDUUNTTyIsInRpdGxlIjoiQ2xhc3MgTTEuNiBzb2xhciBmbGFyZSB0aGF0IHBlYWtlZCBhdCBUdWUsIDMwIEFwciAyMDI0IDAxOjE0OjAwIEdNVCJ9', + 'receiver-rewards': 0, + 'round-time': 1714440885, + sender: 'EHYQCYHUC6CIWZLBX5TDTLVJ4SSVE4RRTMKFDCG4Z4Q7QSQ2XWIQPMKBPU', + 'sender-rewards': 0, + signature: { sig: 'xQjGgT33TOy7dQm0vzDFsHbTDl4BHnzOOp0gHDkexH3Ci5ZFrNgQYnSVyncJ3Cw9MsSybw/3cZwoboK7/O2RDg==' }, + 'tx-type': TransactionType.acfg, + }) + }, + ['mainnet-P4IX7SYWTTFRQGYTCLFOZSTYSJ5FJKNR3MEIVRR4OA2JJXTQZHTQ']: () => { + // Asset config + return new TransactionResultBuilder({ + 'asset-config-transaction': { + 'asset-id': 0, + params: { + creator: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE', + decimals: 0, + 'default-frozen': false, + manager: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE', + name: 'Bad Bunny Society #587', + 'name-b64': encoder.encode('QmFkIEJ1bm55IFNvY2lldHkgIzU4Nw=='), + reserve: 'V4UCC2YXBHLELD7Y6HYSKZI4GABLUG5HE6HAQQ36OBXFEZS7W4VMWB6DUQ', + total: 1, + 'unit-name': 'bbs587', + 'unit-name-b64': encoder.encode('YmJzNTg3'), + url: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}', + 'url-b64': encoder.encode('dGVtcGxhdGUtaXBmczovL3tpcGZzY2lkOjE6cmF3OnJlc2VydmU6c2hhMi0yNTZ9'), + }, + }, + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 23110800, + 'created-asset-index': 854081201, + fee: 1000, + 'first-valid': 23110798, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + 'genesis-id': 'mainnet-v1.0', + group: 'x2iqKcM/w+966sWzIzvH0ySIH9eq7ZCWqGHC6rXILWY=', + id: 'P4IX7SYWTTFRQGYTCLFOZSTYSJ5FJKNR3MEIVRR4OA2JJXTQZHTQ', + 'intra-round-offset': 164, + 'last-valid': 23111798, + note: 'eyJzdGFuZGFyZCI6ImFyYzY5IiwiZGVzY3JpcHRpb24iOiJCYWQgQnVubnkgU29jaWV0eSAjNTg3IiwibWltZV90eXBlIjoiaW1hZ2Uvd2VicCIsInByb3BlcnRpZXMiOnsiQmFja2dyb3VuZCI6IlJlZCIsIlNraW4iOiJQaW5rIiwiRWFyIjoiTXVsdGljb2xvciIsIkJvZHkiOiJPcmFuZ2UgSmFja2V0IiwiTW91dGgiOiJKb2ludCIsIk5vc2UiOiJBY2lkIiwiRXllcyI6IlJhdmUiLCJIZWFkIjoiUmluZyJ9fQ==', + 'receiver-rewards': 0, + 'round-time': 1661696204, + sender: 'JUT54SRAQLZ34MZ7I45KZJG63H3VLJ65VLLOLVVXPIBE3B2C7GFKBF5QAE', + 'sender-rewards': 0, + signature: { sig: 'kQlJT5RVh7TKuQnIkOw4FI9BJ7k++wSuOcXXxLn6srgnAS/sIuTomN2UNzG9DZIFXjoqlap1wpmcNsl5LVvdAw==' }, + 'tx-type': TransactionType.acfg, + }) + }, + ['mainnet-A5MOSCZBJAENBFJ5WDEYYXTTXQAADS6EQFHYLPTHS5WMQ7ZGSM2Q']: () => { + // Asset config creation for 917559 + return new TransactionResultBuilder({ + 'asset-config-transaction': { + 'asset-id': 0, + params: { + clawback: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + creator: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + decimals: 0, + 'default-frozen': false, + freeze: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + manager: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + name: 'TestAsset3', + 'name-b64': encoder.encode('VGVzdEFzc2V0Mw=='), + reserve: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + total: 10000000, + 'unit-name': 'token', + 'unit-name-b64': encoder.encode('dG9rZW4='), + }, + }, + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 6354271, + 'created-asset-index': 917559, + fee: 1000, + 'first-valid': 6354269, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + id: 'A5MOSCZBJAENBFJ5WDEYYXTTXQAADS6EQFHYLPTHS5WMQ7ZGSM2Q', + 'intra-round-offset': 0, + 'last-valid': 6355269, + note: 'l8EIBYWlyQw=', + 'receiver-rewards': 0, + 'round-time': 1588141633, + sender: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + 'sender-rewards': 0, + signature: { + sig: 'bIT1lgB15oU6F9/vQ5ZFLUyPePRkJ/cxmqUFR9kagkljiA3LCzHva/pUg75iiLV59fBleMzXPo+H9OOec9nKDA==', + }, + 'tx-type': TransactionType.acfg, + }) + }, + ['mainnet-HTGK2WBVXTOHV7X5ER3QT3JH2NQSZU43KEMSTHXMJO5D2E3ROT6Q']: () => { + // Asset config reconfigure for 917559 + return new TransactionResultBuilder({ + 'asset-config-transaction': { + 'asset-id': 917559, + params: { + clawback: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + creator: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + decimals: 0, + 'default-frozen': false, + freeze: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + manager: 'MBX2M6J44LQ22L3FROYRBKUAG4FWENPSLPTI7EBR4ECQ2APDMI6XTENHWQ', + reserve: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + total: 0, + }, + }, + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 6354480, + fee: 1000, + 'first-valid': 6354477, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + id: 'HTGK2WBVXTOHV7X5ER3QT3JH2NQSZU43KEMSTHXMJO5D2E3ROT6Q', + 'intra-round-offset': 0, + 'last-valid': 6355477, + note: 'dnuPOsrNYeM=', + 'receiver-rewards': 0, + 'round-time': 1588142543, + sender: 'YA2XBMS34J27VKLIWJQ5AWU7FJASZ6PUNICQOB4PJ2NW4CAX5AHB7RVGMY', + 'sender-rewards': 0, + signature: { + sig: 'Lm1ap4lB/0SyCYK4Gb2UbVyJrAega3OccpeGPMh4GJNyqG4of0fwhPdVigW7ImkaVDTzguYqK3UYV9mEvMxZCg==', + }, + 'tx-type': TransactionType.acfg, + }) + }, + ['mainnet-K66JS73E3BDJ4OYHIC4QRRNSGY2PQMKSQMPYFQ6EEYJTOIPDUA3Q']: () => { + // Asset config + return new TransactionResultBuilder({ + 'asset-config-transaction': { + 'asset-id': 0, + params: { + creator: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE', + decimals: 0, + 'default-frozen': false, + manager: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE', + name: 'Coop #48', + 'name-b64': encoder.encode('Q29vcCAjNDg='), + reserve: '6ZTNQ3SPQEYOWIXZHQR6HSX6CZSQ4FLYOXOCPNJSNRRT6QA2FFD6JIBDSI', + total: 1, + 'unit-name': 'Coop48', + 'unit-name-b64': encoder.encode('Q29vcDQ4'), + url: 'template-ipfs://{ipfscid:1:raw:reserve:sha2-256}', + 'url-b64': encoder.encode('dGVtcGxhdGUtaXBmczovL3tpcGZzY2lkOjE6cmF3OnJlc2VydmU6c2hhMi0yNTZ9'), + }, + }, + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 38688486, + 'created-asset-index': 1820067164, + fee: 1000, + 'first-valid': 38688195, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + 'genesis-id': 'mainnet-v1.0', + group: 'U8DZy01c8WOvL9uuvBKRUpzkpxxsOnotnhN3cfqIZbU=', + id: 'K66JS73E3BDJ4OYHIC4QRRNSGY2PQMKSQMPYFQ6EEYJTOIPDUA3Q', + 'intra-round-offset': 39, + 'last-valid': 38689195, + 'receiver-rewards': 0, + 'round-time': 1715286054, + sender: 'COOPLFOESCTQJVLSFKAA4QURNBDZGMRYJVRH7BRRREB7FFZSHIIA4AVIBE', + 'sender-rewards': 0, + signature: { sig: 'e5usiTCw+xuiY5whu1xESgOqnCmahpDRhJ8fOWIMD80crhgR1O3/05BiMIJnlEvu9icv5+7tarvomyNExdEdDA==' }, + 'tx-type': TransactionType.acfg, + }) + }, } diff --git a/src/tests/setup/mocks.ts b/src/tests/setup/mocks.ts index d5245f3a5..8b5a79cdc 100644 --- a/src/tests/setup/mocks.ts +++ b/src/tests/setup/mocks.ts @@ -23,6 +23,9 @@ vi.mock('@/features/common/data', async () => { disassemble: vi.fn().mockReturnValue({ do: vi.fn(), }), + getAssetByID: vi.fn().mockReturnValue({ + do: vi.fn().mockReturnValue({ then: vi.fn() }), + }), }, indexer: { ...(original.indexer as algosdk.Indexer), @@ -34,6 +37,22 @@ vi.mock('@/features/common/data', async () => { do: vi.fn().mockReturnValue({ then: vi.fn() }), }), }), + searchForTransactions: vi.fn().mockReturnValue({ + assetID: vi.fn().mockReturnValue({ + txType: vi.fn().mockReturnValue({ + do: vi.fn().mockReturnValue({ then: vi.fn() }), + address: vi.fn().mockReturnValue({ + addressRole: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + do: vi.fn().mockReturnValue({ then: vi.fn() }), + }), + }), + }), + }), + }), + }), }, } }) + +global.fetch = vi.fn() diff --git a/src/utils/distinct.ts b/src/utils/distinct.ts new file mode 100644 index 000000000..ce80c2ed7 --- /dev/null +++ b/src/utils/distinct.ts @@ -0,0 +1,12 @@ +export const distinct = (keySelector?: (item: T) => unknown) => { + const ks = keySelector || ((x: T) => x) + const set = new Set() + return (item: T) => { + if (set.has(ks(item))) { + return false + } + + set.add(ks(item)) + return true + } +} diff --git a/src/utils/flatten-inner-transactions.ts b/src/utils/flatten-inner-transactions.ts index fd3402e9b..d6ea2ffaf 100644 --- a/src/utils/flatten-inner-transactions.ts +++ b/src/utils/flatten-inner-transactions.ts @@ -19,9 +19,6 @@ export function flattenInnerTransactions(transaction: Transaction | InnerTransac return results } - const inners: FlattenedTransaction[] = - transaction.innerTransactions.flatMap((transaction) => flattenInnerTransactions(transaction, nestingLevel + 1)) ?? [] - results.push(...inners) - - return results + const inners = transaction.innerTransactions.flatMap((transaction) => flattenInnerTransactions(transaction, nestingLevel + 1)) ?? [] + return results.concat(inners) }