diff --git a/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx b/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx
index d2b2da0570..120af37f16 100644
--- a/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx
+++ b/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx
@@ -1,15 +1,16 @@
import {RouteProp, useRoute} from '@react-navigation/native'
import {isString} from '@yoroi/common'
import {useExplorers} from '@yoroi/explorers'
-import {usePorfolioTokenDiscovery} from '@yoroi/portfolio'
+import {usePorfolioTokenDiscovery, usePorfolioTokenTraits} from '@yoroi/portfolio'
import {useTheme} from '@yoroi/theme'
import {Chain, Portfolio} from '@yoroi/types'
import React, {ReactNode, useState} from 'react'
import {defineMessages, useIntl} from 'react-intl'
import {Linking, SafeAreaView, ScrollView, StyleSheet, TouchableOpacity, useWindowDimensions, View} from 'react-native'
-import {CopyButton, FadeIn, Spacer, Text} from '../../../../components'
+import {Boundary, CopyButton, FadeIn, Spacer, Text} from '../../../../components'
import {Tab, TabPanel, TabPanels, Tabs} from '../../../../components/Tabs'
+import {time} from '../../../../kernel/constants'
import {useMetrics} from '../../../../kernel/metrics/metricsManager'
import {NftRoutes} from '../../../../kernel/navigation'
import {useNavigateTo} from '../../../Nfts/common/navigation'
@@ -65,7 +66,9 @@ export const MediaDetails = () => {
/>
-
+
+
+
@@ -88,7 +91,17 @@ const Details = ({activeTab, info, network}: DetailsProps) => {
getTokenDiscovery: api.tokenDiscovery,
},
{
- staleTime: Infinity,
+ staleTime: time.session,
+ },
+ )
+ const {tokenTraits} = usePorfolioTokenTraits(
+ {
+ id: info.id,
+ network,
+ getTokenTraits: api.tokenTraits,
+ },
+ {
+ staleTime: time.oneDay,
},
)
@@ -98,7 +111,7 @@ const Details = ({activeTab, info, network}: DetailsProps) => {
return (
-
+
@@ -129,7 +142,7 @@ const MetadataRow = ({title, copyText, children}: {title: string; children: Reac
const styles = useStyles()
return (
-
+
{title}
{copyText !== undefined ? : null}
@@ -144,9 +157,10 @@ const MetadataRow = ({title, copyText, children}: {title: string; children: Reac
type NftOverviewProps = {
info: Portfolio.Token.Info
+ traits: Portfolio.Token.Traits | undefined
network: Chain.SupportedNetworks
}
-const NftOverview = ({info, network}: NftOverviewProps) => {
+const NftOverview = ({info, network, traits}: NftOverviewProps) => {
const styles = useStyles()
const strings = useStrings()
const explorers = useExplorers(network)
@@ -171,6 +185,16 @@ const NftOverview = ({info, network}: NftOverviewProps) => {
{policyId}
+ {traits?.traits.map((trait) => (
+
+
+ {trait.value}
+
+ {trait.rarity}
+
+
+ ))}
+
{
rowContainer: {
paddingVertical: imagePadding,
},
- rowTitleContainer: {
+ rowBetween: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
diff --git a/apps/wallet-mobile/src/kernel/constants.ts b/apps/wallet-mobile/src/kernel/constants.ts
index df5758d5d1..b584a8155c 100644
--- a/apps/wallet-mobile/src/kernel/constants.ts
+++ b/apps/wallet-mobile/src/kernel/constants.ts
@@ -1,11 +1,13 @@
-export const supportedThemes = Object.freeze({
+import {freeze} from 'immer'
+
+export const supportedThemes = freeze({
system: 'system',
'default-light': 'default-light',
'default-dark': 'default-dark',
})
// NOTE: to be moved into pairing module once it's implemented
-export const supportedCurrencies = Object.freeze({
+export const supportedCurrencies = freeze({
ADA: 'ADA',
BRL: 'BRL',
BTC: 'BTC',
@@ -17,7 +19,7 @@ export const supportedCurrencies = Object.freeze({
USD: 'USD',
})
-export const configCurrencies = {
+export const configCurrencies = freeze({
[supportedCurrencies.ADA]: {
decimals: 6,
nativeName: 'Cardano',
@@ -54,4 +56,14 @@ export const configCurrencies = {
decimals: 2,
nativeName: 'US Dollar',
},
-}
+})
+
+export const time = freeze({
+ oneMinute: 60 * 1e3,
+ fiveMinutes: 5 * 60 * 1e3,
+ halfHour: 30 * 60 * 1e3,
+ oneHour: 60 * 60 * 1e3,
+ oneDay: 24 * 60 * 60 * 1e3,
+ // session here means while the wallet is open
+ session: Infinity,
+})
diff --git a/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json b/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json
index 580fc42b71..64e0bb0f12 100644
--- a/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json
+++ b/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json
@@ -4,14 +4,14 @@
"defaultMessage": "!!!NFT Details",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 315,
+ "line": 339,
"column": 9,
- "index": 8864
+ "index": 9572
},
"end": {
- "line": 318,
+ "line": 342,
"column": 3,
- "index": 8935
+ "index": 9643
}
},
{
@@ -19,14 +19,14 @@
"defaultMessage": "!!!Overview",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 319,
+ "line": 343,
"column": 12,
- "index": 8949
+ "index": 9657
},
"end": {
- "line": 322,
+ "line": 346,
"column": 3,
- "index": 9020
+ "index": 9728
}
},
{
@@ -34,14 +34,14 @@
"defaultMessage": "!!!Metadata",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 323,
+ "line": 347,
"column": 12,
- "index": 9034
+ "index": 9742
},
"end": {
- "line": 326,
+ "line": 350,
"column": 3,
- "index": 9105
+ "index": 9813
}
},
{
@@ -49,14 +49,14 @@
"defaultMessage": "!!!NFT Name",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 327,
+ "line": 351,
"column": 11,
- "index": 9118
+ "index": 9826
},
"end": {
- "line": 330,
+ "line": 354,
"column": 3,
- "index": 9188
+ "index": 9896
}
},
{
@@ -64,14 +64,14 @@
"defaultMessage": "!!!Created",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 331,
+ "line": 355,
"column": 13,
- "index": 9203
+ "index": 9911
},
"end": {
- "line": 334,
+ "line": 358,
"column": 3,
- "index": 9274
+ "index": 9982
}
},
{
@@ -79,14 +79,14 @@
"defaultMessage": "!!!Description",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 335,
+ "line": 359,
"column": 15,
- "index": 9291
+ "index": 9999
},
"end": {
- "line": 338,
+ "line": 362,
"column": 3,
- "index": 9368
+ "index": 10076
}
},
{
@@ -94,14 +94,14 @@
"defaultMessage": "!!!Author",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 339,
+ "line": 363,
"column": 10,
- "index": 9380
+ "index": 10088
},
"end": {
- "line": 342,
+ "line": 366,
"column": 3,
- "index": 9447
+ "index": 10155
}
},
{
@@ -109,14 +109,14 @@
"defaultMessage": "!!!Fingerprint",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 343,
+ "line": 367,
"column": 15,
- "index": 9464
+ "index": 10172
},
"end": {
- "line": 346,
+ "line": 370,
"column": 3,
- "index": 9541
+ "index": 10249
}
},
{
@@ -124,14 +124,14 @@
"defaultMessage": "!!!Policy id",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 347,
+ "line": 371,
"column": 12,
- "index": 9555
+ "index": 10263
},
"end": {
- "line": 350,
+ "line": 374,
"column": 3,
- "index": 9627
+ "index": 10335
}
},
{
@@ -139,14 +139,14 @@
"defaultMessage": "!!!Details on",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 351,
+ "line": 375,
"column": 16,
- "index": 9645
+ "index": 10353
},
"end": {
- "line": 354,
+ "line": 378,
"column": 3,
- "index": 9722
+ "index": 10430
}
},
{
@@ -154,14 +154,14 @@
"defaultMessage": "!!!Copy metadata",
"file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx",
"start": {
- "line": 355,
+ "line": 379,
"column": 16,
- "index": 9740
+ "index": 10448
},
"end": {
- "line": 358,
+ "line": 382,
"column": 3,
- "index": 9820
+ "index": 10528
}
}
]
\ No newline at end of file
diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts
index 34f0260145..1e2cf24ee1 100644
--- a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts
+++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts
@@ -3,6 +3,7 @@ import {asyncBehavior} from '@yoroi/common'
import {Api, Portfolio} from '@yoroi/types'
import {tokenMocks} from '../token.mocks'
+import {tokenTraitsMocks} from '../token-traits.mocks'
export const responseTokenDiscoveryMocks = asyncBehavior.maker<
Api.Response
@@ -24,19 +25,32 @@ export const responseTokenInfosMocks = asyncBehavior.maker<
emptyRepresentation: null,
})
+export const responseTokenTraits = asyncBehavior.maker<
+ Api.Response
+>({
+ data: {
+ tag: 'right',
+ value: {status: 200, data: tokenTraitsMocks.nftCryptoKitty},
+ },
+ emptyRepresentation: null,
+})
+
const success: Portfolio.Api.Api = {
tokenDiscovery: responseTokenDiscoveryMocks.success,
tokenInfos: responseTokenInfosMocks.success,
+ tokenTraits: responseTokenTraits.success,
}
const delayed: Portfolio.Api.Api = {
tokenDiscovery: responseTokenDiscoveryMocks.delayed,
tokenInfos: responseTokenInfosMocks.delayed,
+ tokenTraits: responseTokenTraits.delayed,
}
const loading: Portfolio.Api.Api = {
tokenDiscovery: responseTokenDiscoveryMocks.loading,
tokenInfos: responseTokenInfosMocks.loading,
+ tokenTraits: responseTokenTraits.loading,
}
const error: Portfolio.Api.Api = {
@@ -58,11 +72,21 @@ const error: Portfolio.Api.Api = {
responseData: {message: 'Bad Request'},
},
}),
+ tokenTraits: () =>
+ Promise.resolve({
+ tag: 'left',
+ error: {
+ status: 400,
+ message: 'Bad Request',
+ responseData: {message: 'Bad Request'},
+ },
+ }),
}
const empty: Portfolio.Api.Api = {
tokenDiscovery: responseTokenDiscoveryMocks.empty,
tokenInfos: responseTokenInfosMocks.empty,
+ tokenTraits: responseTokenTraits.empty,
}
export const portfolioApiMock = freeze(
diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts
index 1d9790f6ce..c26f22ae44 100644
--- a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts
+++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts
@@ -3,6 +3,7 @@ import {Api, Chain, Portfolio} from '@yoroi/types'
import {apiConfig, portfolioApiMaker} from './api-maker'
import {DullahanApiCachedIdsRequest} from './types'
import {tokenDiscoveryMocks} from '../token-discovery.mocks'
+import {tokenMocks} from '../token.mocks'
describe('portfolioApiMaker', () => {
const mockNetwork: Chain.Network = Chain.Network.Mainnet
@@ -24,6 +25,7 @@ describe('portfolioApiMaker', () => {
expect(api).toBeDefined()
expect(api).toHaveProperty('tokenDiscovery')
expect(api).toHaveProperty('tokenInfos')
+ expect(api).toHaveProperty('tokenTraits')
})
it('should return a PortfolioApi object with default fetchData (coverage)', () => {
@@ -36,6 +38,7 @@ describe('portfolioApiMaker', () => {
expect(api).toBeDefined()
expect(api).toHaveProperty('tokenDiscovery')
expect(api).toHaveProperty('tokenInfos')
+ expect(api).toHaveProperty('tokenTraits')
})
it('should call the fetchData function with the correct arguments', async () => {
@@ -61,8 +64,10 @@ describe('portfolioApiMaker', () => {
await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id)
await api.tokenInfos(mockTokenIdsWithCache)
+ await api.tokenTraits(tokenMocks.nftCryptoKitty.info.id)
+
+ expect(mockRequest).toHaveBeenCalledTimes(3)
- expect(mockRequest).toHaveBeenCalledTimes(2)
expect(mockRequest).toHaveBeenCalledWith({
method: 'get',
url:
@@ -83,6 +88,17 @@ describe('portfolioApiMaker', () => {
'Content-Type': 'application/json',
},
})
+ expect(mockRequest).toHaveBeenCalledWith({
+ method: 'get',
+ url:
+ apiConfig[Chain.Network.Mainnet].tokenTraits +
+ '/' +
+ tokenMocks.nftCryptoKitty.info.id,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
})
it('should return error when returning data is malformed', async () => {
@@ -108,8 +124,9 @@ describe('portfolioApiMaker', () => {
'token.id:etag-hash',
]
- await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id)
-
+ const resultDiscovery = await api.tokenDiscovery(
+ tokenDiscoveryMocks.nftCryptoKitty.id,
+ )
expect(mockRequest).toHaveBeenCalledTimes(1)
expect(mockRequest).toHaveBeenCalledWith({
method: 'get',
@@ -122,8 +139,18 @@ describe('portfolioApiMaker', () => {
'Content-Type': 'application/json',
},
})
+ expect(resultDiscovery).toEqual({
+ tag: 'left',
+ error: {
+ status: -3,
+ message: 'Failed to transform token discovery response',
+ responseData: {
+ ['wrong']: 'data',
+ },
+ },
+ })
- const result = await api.tokenInfos(mockTokenIdsWithCache)
+ const resultTokenInfos = await api.tokenInfos(mockTokenIdsWithCache)
expect(mockRequest).toHaveBeenCalledTimes(2)
expect(mockRequest).toHaveBeenCalledWith({
method: 'post',
@@ -135,7 +162,7 @@ describe('portfolioApiMaker', () => {
},
})
- expect(result).toEqual({
+ expect(resultTokenInfos).toEqual({
tag: 'left',
error: {
status: -3,
@@ -145,6 +172,32 @@ describe('portfolioApiMaker', () => {
},
},
})
+
+ const resultTraits = await api.tokenTraits(
+ tokenMocks.nftCryptoKitty.info.id,
+ )
+ expect(mockRequest).toHaveBeenCalledTimes(3)
+ expect(mockRequest).toHaveBeenCalledWith({
+ method: 'get',
+ url:
+ apiConfig[Chain.Network.Mainnet].tokenTraits +
+ '/' +
+ tokenMocks.nftCryptoKitty.info.id,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
+ expect(resultTraits).toEqual({
+ tag: 'left',
+ error: {
+ status: -3,
+ message: 'Failed to transform token traits response',
+ responseData: {
+ ['wrong']: 'data',
+ },
+ },
+ })
})
it('should return the error and not throw', async () => {
@@ -169,8 +222,14 @@ describe('portfolioApiMaker', () => {
'token.id:etag-hash',
]
- await api.tokenInfos(mockTokenIdsWithCache)
-
+ await expect(api.tokenInfos(mockTokenIdsWithCache)).resolves.toEqual({
+ tag: 'left',
+ value: {
+ status: 500,
+ message: 'Internal Server Error',
+ responseData: {},
+ },
+ })
expect(mockRequest).toHaveBeenCalledTimes(1)
expect(mockRequest).toHaveBeenCalledWith({
method: 'post',
@@ -182,7 +241,16 @@ describe('portfolioApiMaker', () => {
},
})
- await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id)
+ await expect(
+ api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id),
+ ).resolves.toEqual({
+ tag: 'left',
+ value: {
+ status: 500,
+ message: 'Internal Server Error',
+ responseData: {},
+ },
+ })
expect(mockRequest).toHaveBeenCalledTimes(2)
expect(mockRequest).toHaveBeenCalledWith({
method: 'get',
@@ -195,6 +263,66 @@ describe('portfolioApiMaker', () => {
'Content-Type': 'application/json',
},
})
+
+ await expect(
+ api.tokenTraits(tokenMocks.nftCryptoKitty.info.id),
+ ).resolves.toEqual({
+ tag: 'left',
+ value: {
+ status: 500,
+ message: 'Internal Server Error',
+ responseData: {},
+ },
+ })
+ expect(mockRequest).toHaveBeenCalledTimes(3)
+ expect(mockRequest).toHaveBeenCalledWith({
+ method: 'get',
+ url:
+ apiConfig[Chain.Network.Mainnet].tokenTraits +
+ '/' +
+ tokenMocks.nftCryptoKitty.info.id,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
+ })
+
+ it('should return the data on success', async () => {
+ mockRequest.mockResolvedValue({
+ tag: 'right',
+ value: {
+ status: 200,
+ data: tokenMocks.nftCryptoKitty.traits,
+ },
+ })
+ const api = portfolioApiMaker({
+ network: mockNetwork,
+ request: mockRequest,
+ maxIdsPerRequest: 10,
+ maxConcurrentRequests: 10,
+ })
+
+ const result = await api.tokenTraits(tokenMocks.nftCryptoKitty.info.id)
+ expect(mockRequest).toHaveBeenCalledTimes(1)
+ expect(mockRequest).toHaveBeenCalledWith({
+ method: 'get',
+ url:
+ apiConfig[Chain.Network.Mainnet].tokenTraits +
+ '/' +
+ tokenMocks.nftCryptoKitty.info.id,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
+ expect(result).toEqual({
+ tag: 'right',
+ value: {
+ status: 200,
+ data: tokenMocks.nftCryptoKitty.traits,
+ },
+ })
})
it('should return error when returning data is malformed token-discovery', async () => {
diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts
index 5f235f34f9..e8ff0fb1d1 100644
--- a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts
+++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts
@@ -14,8 +14,10 @@ import {
DullahanApiCachedIdsRequest,
DullahanApiTokenDiscoveryResponse,
DullahanApiTokenInfosResponse,
+ DullahanApiTokenTraitsResponse,
} from './types'
import {parseTokenDiscovery} from '../../validators/token-discovery'
+import {parseTokenTraits} from '../../validators/token-traits'
export const portfolioApiMaker = ({
network,
@@ -134,6 +136,48 @@ export const portfolioApiMaker = ({
)
}
},
+ async tokenTraits(id) {
+ const response = await request({
+ method: 'get',
+ url: `${config.tokenTraits}/${id}`,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ })
+ if (isRight(response)) {
+ const traits: Portfolio.Token.Traits | undefined = parseTokenTraits(
+ response.value.data,
+ )
+
+ if (!traits) {
+ return freeze(
+ {
+ tag: 'left',
+ error: {
+ status: -3,
+ message: 'Failed to transform token traits response',
+ responseData: response.value.data,
+ },
+ },
+ true,
+ )
+ }
+
+ return freeze(
+ {
+ tag: 'right',
+ value: {
+ status: response.value.status,
+ data: traits,
+ },
+ },
+ true,
+ )
+ }
+
+ return response
+ },
},
true,
)
@@ -142,22 +186,25 @@ export const portfolioApiMaker = ({
export const apiConfig: ApiConfig = freeze(
{
mainnet: {
- tokenDiscovery:
- 'https://dev-yoroi-backend-zero-mainnet.emurgornd.com/tokens/discovery',
- tokenInfos:
- 'https://dev-yoroi-backend-zero-mainnet.emurgornd.com/tokens/info/multi',
+ tokenDiscovery: 'https://zero.yoroiwallet.com/tokens/discovery',
+ tokenInfos: 'https://zero.yoroiwallet.com/tokens/info/multi',
+ tokenTraits: 'https://zero.yoroiwallet.com/tokens/nft/traits',
},
preprod: {
tokenDiscovery:
'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/discovery',
tokenInfos:
'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/info/multi',
+ tokenTraits:
+ 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/nft/traits',
},
sancho: {
tokenDiscovery:
'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/discovery',
tokenInfos:
'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/info/multi',
+ tokenTraits:
+ 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/nft/traits',
},
},
true,
diff --git a/packages/portfolio/src/adapters/dullahan-api/types.ts b/packages/portfolio/src/adapters/dullahan-api/types.ts
index 2845f50df7..f76b02b238 100644
--- a/packages/portfolio/src/adapters/dullahan-api/types.ts
+++ b/packages/portfolio/src/adapters/dullahan-api/types.ts
@@ -6,6 +6,13 @@ export type DullahanApiTokenInfosResponse = Readonly<{
>
}>
+export type DullahanApiTokenTraitsResponse = Readonly<
+ Portfolio.Token.Traits & {
+ collection: string
+ name: string
+ }
+>
+
export type DullahanTokenDiscovery = Omit<
Portfolio.Token.Discovery,
'supply'
diff --git a/packages/portfolio/src/adapters/token-traits.mocks.ts b/packages/portfolio/src/adapters/token-traits.mocks.ts
new file mode 100644
index 0000000000..95a1477d56
--- /dev/null
+++ b/packages/portfolio/src/adapters/token-traits.mocks.ts
@@ -0,0 +1,48 @@
+import {Api, Portfolio} from '@yoroi/types'
+import {freeze} from 'immer'
+
+const nftCryptoKitty: Portfolio.Token.Traits = {
+ totalItems: 1,
+ traits: [
+ {type: 'rarity', value: 'common', rarity: 'common'},
+ {type: 'color', value: 'blue', rarity: 'common'},
+ ],
+}
+
+const rnftWhatever: Portfolio.Token.Traits = {
+ totalItems: 1,
+ traits: [
+ {type: 'rarity', value: 'rare', rarity: 'rare'},
+ {type: 'color', value: 'red', rarity: 'rare'},
+ ],
+}
+
+const apiResponseTokenTraits: Readonly<
+ Record<'success' | 'error', Api.Response>
+> = freeze(
+ {
+ success: {
+ tag: 'right',
+ value: {
+ status: 200,
+ data: nftCryptoKitty,
+ },
+ },
+ error: {
+ tag: 'left',
+ error: {
+ status: 404,
+ responseData: null,
+ message: 'Not found',
+ },
+ },
+ },
+ true,
+)
+
+export const tokenTraitsMocks = freeze({
+ nftCryptoKitty,
+ rnftWhatever,
+
+ apiResponse: apiResponseTokenTraits,
+})
diff --git a/packages/portfolio/src/adapters/token.mocks.ts b/packages/portfolio/src/adapters/token.mocks.ts
index dce4859abd..b3470fbaec 100644
--- a/packages/portfolio/src/adapters/token.mocks.ts
+++ b/packages/portfolio/src/adapters/token.mocks.ts
@@ -3,6 +3,7 @@ import {freeze} from 'immer'
import * as infos from './token-info.mocks'
import * as discoveries from './token-discovery.mocks'
import * as balances from './token-balance.mocks'
+import * as traits from './token-traits.mocks'
export const tokenMocks = freeze(
{
@@ -10,6 +11,7 @@ export const tokenMocks = freeze(
info: infos.tokenInfoMocks.nftCryptoKitty,
discovery: discoveries.tokenDiscoveryMocks.nftCryptoKitty,
balance: balances.tokenBalanceMocks.nftCryptoKitty,
+ traits: traits.tokenTraitsMocks.nftCryptoKitty,
},
primaryETH: {
info: infos.tokenInfoMocks.primaryETH,
@@ -21,6 +23,7 @@ export const tokenMocks = freeze(
info: infos.tokenInfoMocks.rnftWhatever,
discovery: discoveries.tokenDiscoveryMocks.rnftWhatever,
balance: balances.tokenBalanceMocks.rnftWhatever,
+ traits: traits.tokenTraitsMocks.rnftWhatever,
},
apiResponse: {
tokenInfos: infos.tokenInfoMocks.apiResponseResult,
diff --git a/packages/portfolio/src/balance-manager.test.ts b/packages/portfolio/src/balance-manager.test.ts
index db674f39f1..7d31fb583a 100644
--- a/packages/portfolio/src/balance-manager.test.ts
+++ b/packages/portfolio/src/balance-manager.test.ts
@@ -48,6 +48,7 @@ describe('portfolioBalanceManagerMaker', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
@@ -92,6 +93,7 @@ describe('hydrate', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
@@ -178,6 +180,7 @@ describe('destroy', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
const queueDestroy = jest.fn()
@@ -258,6 +261,7 @@ describe('primary updates', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
@@ -413,6 +417,7 @@ describe('sync & refresh', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
@@ -701,6 +706,7 @@ describe('clear', () => {
api: {
tokenInfos: jest.fn(),
tokenDiscovery: jest.fn(),
+ tokenTraits: jest.fn(),
},
}
diff --git a/packages/portfolio/src/index.ts b/packages/portfolio/src/index.ts
index ff8c0c81a7..3409c1dce8 100644
--- a/packages/portfolio/src/index.ts
+++ b/packages/portfolio/src/index.ts
@@ -33,8 +33,10 @@ export * from './validators/token-property-type'
export * from './validators/token-source'
export * from './validators/token-status'
export * from './validators/token-type'
+export * from './validators/token-traits'
export * from './translators/reactjs/usePortfolioTokenDiscovery'
+export * from './translators/reactjs/usePortfolioTokenTraits'
export * from './balance-manager'
export * from './token-manager'
diff --git a/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx
new file mode 100644
index 0000000000..c9b8d9dec1
--- /dev/null
+++ b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx
@@ -0,0 +1,93 @@
+import * as React from 'react'
+import {QueryClient} from 'react-query'
+import {Text, View} from 'react-native'
+import {render, waitFor} from '@testing-library/react-native'
+import {queryClientFixture} from '@yoroi/common'
+import {Chain} from '@yoroi/types'
+
+import {usePorfolioTokenTraits} from './usePortfolioTokenTraits'
+import {wrapperMaker} from '../../fixtures/wrapperMaker'
+import {tokenMocks} from '../../adapters/token.mocks'
+import {tokenTraitsMocks} from '../../adapters/token-traits.mocks'
+
+describe('usePortfolioTokenTraits', () => {
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ queryClient = queryClientFixture()
+ })
+
+ afterEach(() => {
+ queryClient.clear()
+ })
+
+ it('success', async () => {
+ const mockedGetTokenTraits = jest
+ .fn()
+ .mockResolvedValue(tokenTraitsMocks.apiResponse.success)
+
+ const TestComponent = () => {
+ const {data} = usePorfolioTokenTraits(
+ {
+ id: tokenMocks.nftCryptoKitty.info.id,
+ network: Chain.Network.Mainnet,
+ getTokenTraits: mockedGetTokenTraits,
+ },
+ {
+ suspense: true,
+ },
+ )
+ return (
+
+ {JSON.stringify(data?.totalItems)}
+
+ )
+ }
+ const wrapper = wrapperMaker({
+ queryClient,
+ })
+ const {getByTestId} = render(, {wrapper})
+
+ await waitFor(() => {
+ expect(getByTestId('data')).toBeDefined()
+ })
+
+ expect(getByTestId('data').props.children).toEqual(JSON.stringify(1))
+ expect(mockedGetTokenTraits).toHaveBeenCalledWith(
+ tokenMocks.nftCryptoKitty.info.id,
+ )
+ })
+
+ it('error', async () => {
+ const mockedGetTokenTraits = jest
+ .fn()
+ .mockResolvedValue(tokenTraitsMocks.apiResponse.error)
+
+ const TestComponent = () => {
+ const {data} = usePorfolioTokenTraits(
+ {
+ id: tokenMocks.nftCryptoKitty.info.id,
+ network: Chain.Network.Mainnet,
+ getTokenTraits: mockedGetTokenTraits,
+ },
+ {
+ suspense: true,
+ },
+ )
+ return (
+
+ {JSON.stringify(data?.totalItems)}
+
+ )
+ }
+ const wrapper = wrapperMaker({
+ queryClient,
+ })
+ const {getByTestId} = render(, {wrapper})
+
+ await waitFor(() => {
+ expect(getByTestId('hasError')).toBeDefined()
+ })
+ })
+})
diff --git a/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx
new file mode 100644
index 0000000000..047ac43251
--- /dev/null
+++ b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx
@@ -0,0 +1,36 @@
+import {isRight} from '@yoroi/common'
+import {Chain, Portfolio} from '@yoroi/types'
+import {UseQueryOptions, useQuery} from 'react-query'
+
+export function usePorfolioTokenTraits(
+ {
+ id,
+ getTokenTraits,
+ network,
+ }: {
+ id: Portfolio.Token.Id
+ getTokenTraits: Portfolio.Api.Api['tokenTraits']
+ network: Chain.SupportedNetworks
+ },
+ options?: UseQueryOptions<
+ Portfolio.Token.Traits,
+ Error,
+ Portfolio.Token.Traits,
+ [Chain.SupportedNetworks, 'usePorfolioTokenTraits', Portfolio.Token.Id]
+ >,
+) {
+ const query = useQuery({
+ queryKey: [network, 'usePorfolioTokenTraits', id],
+ ...options,
+ queryFn: async () => {
+ const response = await getTokenTraits(id)
+ if (isRight(response)) return response.value.data
+ throw new Error('usePorfolioTokenTraits')
+ },
+ })
+
+ return {
+ ...query,
+ tokenTraits: query.data,
+ }
+}
diff --git a/packages/portfolio/src/validators/token-traits.test.ts b/packages/portfolio/src/validators/token-traits.test.ts
new file mode 100644
index 0000000000..76b5fe8fa5
--- /dev/null
+++ b/packages/portfolio/src/validators/token-traits.test.ts
@@ -0,0 +1,87 @@
+import {isTokenTraits, parseTokenTraits} from './token-traits'
+
+describe('Token Traits Validator', () => {
+ describe('isTokenTraits', () => {
+ it('should return true for a valid token traits object', () => {
+ const validTokenTraits = {
+ totalItems: 5,
+ traits: [
+ {
+ type: 'type1',
+ value: 'value1',
+ rarity: 'rarity1',
+ },
+ {
+ type: 'type2',
+ value: 'value2',
+ rarity: 'rarity2',
+ },
+ ],
+ }
+
+ expect(isTokenTraits(validTokenTraits)).toBe(true)
+ })
+
+ it('should return false for an invalid token traits object', () => {
+ const invalidTokenTraits = {
+ totalItems: '5',
+ traits: [
+ {
+ type: 'type1',
+ value: 'value1',
+ rarity: 'rarity1',
+ },
+ {
+ type: 'type2',
+ value: 'value2',
+ rarity: 'rarity2',
+ },
+ ],
+ }
+
+ expect(isTokenTraits(invalidTokenTraits)).toBe(false)
+ })
+ })
+
+ describe('parseTokenTraits', () => {
+ it('should return the token traits object if it is valid', () => {
+ const validTokenTraits = {
+ totalItems: 5,
+ traits: [
+ {
+ type: 'type1',
+ value: 'value1',
+ rarity: 'rarity1',
+ },
+ {
+ type: 'type2',
+ value: 'value2',
+ rarity: 'rarity2',
+ },
+ ],
+ }
+
+ expect(parseTokenTraits(validTokenTraits)).toEqual(validTokenTraits)
+ })
+
+ it('should return undefined if the token traits object is invalid', () => {
+ const invalidTokenTraits = {
+ totalItems: '5',
+ traits: [
+ {
+ type: 'type1',
+ value: 'value1',
+ rarity: 'rarity1',
+ },
+ {
+ type: 'type2',
+ value: 'value2',
+ rarity: 'rarity2',
+ },
+ ],
+ }
+
+ expect(parseTokenTraits(invalidTokenTraits)).toBeUndefined()
+ })
+ })
+})
diff --git a/packages/portfolio/src/validators/token-traits.ts b/packages/portfolio/src/validators/token-traits.ts
new file mode 100644
index 0000000000..a305ca132b
--- /dev/null
+++ b/packages/portfolio/src/validators/token-traits.ts
@@ -0,0 +1,25 @@
+import {Portfolio} from '@yoroi/types'
+import {z} from 'zod'
+
+export const TraitSchema = z.object({
+ type: z.string(),
+ value: z.string(),
+ rarity: z.string(),
+})
+
+const TraitsSchema = z.object({
+ totalItems: z.number().nonnegative(),
+ traits: z.array(TraitSchema),
+ // useful only if client doen't have the info
+ // collection: z.string().optional(),
+ // name: z.string().optional(),
+})
+
+export const isTokenTraits = (data: unknown): data is Portfolio.Token.Traits =>
+ TraitsSchema.safeParse(data).success
+
+export const parseTokenTraits = (
+ data: unknown,
+): Portfolio.Token.Traits | undefined => {
+ return isTokenTraits(data) ? data : undefined
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 9ae57511b1..0baf2e96ba 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -171,6 +171,7 @@ import {
PortfolioApi,
PortfolioApiTokenDiscoveryResponse,
PortfolioApiTokenInfosResponse,
+ PortfolioApiTokenTraitsResponse,
} from './portfolio/api'
import {
PortfolioEventBalanceManager,
@@ -198,6 +199,7 @@ import {
import {AppQueueTask, AppQueueTaskManager} from './app/queue-task-manager'
import {ExplorersManager} from './explorers/manager'
import {ExplorersExplorer} from './explorers/explorer'
+import {PortfolioTokenTraits} from './portfolio/traits'
export namespace App {
export namespace Errors {
@@ -454,6 +456,7 @@ export namespace Portfolio {
export namespace Api {
export type TokenInfosResponse = PortfolioApiTokenInfosResponse
export type TokenDiscoveryResponse = PortfolioApiTokenDiscoveryResponse
+ export type TokenTraitsResponse = PortfolioApiTokenTraitsResponse
export type Api = PortfolioApi
}
@@ -468,6 +471,7 @@ export namespace Portfolio {
}
export namespace Token {
+ export type Traits = PortfolioTokenTraits
export type Balances = PortfolioTokenBalances
export type Amount = PortfolioTokenAmount
export type AmountRecords = PortfolioTokenAmountRecords
diff --git a/packages/types/src/portfolio/api.ts b/packages/types/src/portfolio/api.ts
index 706cfdf323..80a51264f8 100644
--- a/packages/types/src/portfolio/api.ts
+++ b/packages/types/src/portfolio/api.ts
@@ -6,6 +6,7 @@ import {ApiResponse} from '../api/response'
import {PortfolioTokenDiscovery} from './discovery'
import {PortfolioTokenInfo} from './info'
import {PortfolioTokenId} from './token'
+import {PortfolioTokenTraits} from './traits'
export type PortfolioApiTokenDiscoveryResponse = PortfolioTokenDiscovery
@@ -13,6 +14,8 @@ export type PortfolioApiTokenInfosResponse = {
[key: PortfolioTokenId]: ApiResponseRecordWithCache
}
+export type PortfolioApiTokenTraitsResponse = PortfolioTokenTraits
+
export type PortfolioApi = Readonly<{
tokenInfos(
idsWithETag: ReadonlyArray>,
@@ -20,4 +23,7 @@ export type PortfolioApi = Readonly<{
tokenDiscovery(
id: PortfolioTokenId,
): Promise>>
+ tokenTraits(
+ id: PortfolioTokenId,
+ ): Promise>>
}>
diff --git a/packages/types/src/portfolio/traits.ts b/packages/types/src/portfolio/traits.ts
new file mode 100644
index 0000000000..7f41651caa
--- /dev/null
+++ b/packages/types/src/portfolio/traits.ts
@@ -0,0 +1,10 @@
+export type PortfolioTokenTrait = {
+ type: string
+ value: string
+ rarity: string
+}
+
+export type PortfolioTokenTraits = {
+ totalItems: number
+ traits: Array
+}