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}
+
+
+
+
+
+
+ {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.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