Skip to content

Commit

Permalink
refactor(packages): added zod for api transformers (#2474)
Browse files Browse the repository at this point in the history
Co-authored-by: stackchain <30806844+stackchain@users.noreply.github.com>
  • Loading branch information
michaeljscript and stackchain committed May 23, 2023
1 parent ed20db2 commit f58294b
Show file tree
Hide file tree
Showing 21 changed files with 526 additions and 377 deletions.
3 changes: 2 additions & 1 deletion apps/wallet-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@
"url": "^0.11.0",
"util": "0.12.4",
"uuid": "^3.3.2",
"vm-browserify": "1.1.2"
"vm-browserify": "1.1.2",
"zod": "^3.21.4"
},
"devDependencies": {
"@babel/cli": "^7.15.7",
Expand Down
7 changes: 4 additions & 3 deletions apps/wallet-mobile/src/components/NftPreview/NftPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import SkeletonPlaceholder from 'react-native-skeleton-placeholder'
import {SvgUri} from 'react-native-svg'

import placeholder from '../../assets/img/nft-placeholder.png'
import {getNftFilenameMediaType, isSvgMediaType} from '../../yoroi-wallets/cardano/nfts'
import {getNftFilenameMediaType, getNftMainImageMediaType, isSvgMediaType} from '../../yoroi-wallets/cardano/nfts'
import {TokenInfo} from '../../yoroi-wallets/types'
import {isRecord, isString} from '../../yoroi-wallets/utils'
import {isString} from '../../yoroi-wallets/utils'

export const NftPreview = ({
nft,
Expand All @@ -34,8 +34,9 @@ export const NftPreview = ({
const isUriSvg =
isString(uri) &&
(uri.toLowerCase().endsWith('.svg') ||
(isRecord(nft.metadatas.mintNft) && isSvgMediaType(nft.metadatas.mintNft?.mediaType)) ||
isSvgMediaType(getNftMainImageMediaType(nft)) ||
isSvgMediaType(getNftFilenameMediaType(nft, uri)))

const shouldShowPlaceholder = !isString(uri) || showPlaceholder || (isUriSvg && blurRadius !== undefined) || error

useEffect(() => {
Expand Down
203 changes: 6 additions & 197 deletions apps/wallet-mobile/src/yoroi-wallets/cardano/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import assert from 'assert'
import _ from 'lodash'

import {promiseAny} from '../../../utils'
import type {
AccountStateRequest,
AccountStateResponse,
Expand All @@ -14,21 +13,21 @@ import type {
RawTransaction,
StakePoolInfosAndHistories,
TipStatusResponse,
TokenInfo,
TxHistoryRequest,
TxStatusRequest,
TxStatusResponse,
} from '../../types'
import {NFTAsset, YoroiNftModerationStatus} from '../../types'
import {hasProperties, isArray, isNonNullable, isNumber, isObject, isRecord} from '../../utils/parsing'
import {ApiError} from '../errors'
import {convertNft} from '../nfts'
import {ServerStatus} from '../types'
import fetchDefault, {checkedFetch} from './fetch'
import {fallbackTokenInfo, toAssetName, toAssetNameHex, tokenInfo, toPolicyId, toTokenSubject} from './utils'
import fetchDefault from './fetch'

type Addresses = Array<string>

export {fetchTokensSupplies} from './assetSuply'
export {getNFTs} from './metadata'
export {getNFTModerationStatus} from './nftModerationStatus'
export {getTokenInfo} from './tokenRegistry'

export const checkServerStatus = (config: BackendConfig): Promise<ServerStatus> =>
fetchDefault('status', null, config, 'GET') as any

Expand Down Expand Up @@ -89,106 +88,6 @@ export const getPoolInfo = (request: PoolInfoRequest, config: BackendConfig): Pr
return fetchDefault('pool/info', request, config)
}

export const getNFTs = async (ids: string[], config: BackendConfig): Promise<TokenInfo[]> => {
if (ids.length === 0) {
return []
}
const assets = ids.map((id) => {
const policy = toPolicyId(id)
const nameHex = toAssetNameHex(id)
return {policy, nameHex}
})

const payload = {assets}

const [assetMetadatas, assetSupplies] = await Promise.all([
fetchDefault<unknown>('multiAsset/metadata', payload, config),
fetchTokensSupplies(ids, config),
])

const possibleNfts = parseNFTs(assetMetadatas, config.NFT_STORAGE_URL)
return possibleNfts.filter((nft) => assetSupplies[nft.id] === 1)
}

export const getNFT = async (id: string, config: BackendConfig): Promise<TokenInfo | null> => {
const [nft] = await getNFTs([id], config)
return nft || null
}

export const fetchTokensSupplies = async (
tokenIds: string[],
config: BackendConfig,
): Promise<Record<string, number | null>> => {
const assets = tokenIds.map((tokenId) => ({policy: toPolicyId(tokenId), name: toAssetName(tokenId) || ''}))
const response = await fetchDefault<unknown>('multiAsset/supply', {assets}, config)
const supplies = assets.map((asset) => {
const key = `${asset.policy}.${asset.name}`

const supply =
isRecord(response) &&
hasProperties(response, ['supplies']) &&
isRecord(response.supplies) &&
hasProperties(response.supplies, [key])
? response.supplies[key]
: null

return isNumber(supply) ? supply : null
})
return Object.fromEntries(tokenIds.map((tokenId, index) => [tokenId, supplies[index]]))
}

export const getNFTModerationStatus = async (
fingerprint: string,
config: BackendConfig & {mainnet: boolean},
): Promise<YoroiNftModerationStatus> => {
return fetchDefault(
'multiAsset/validateNFT/' + fingerprint,
config.mainnet ? {envName: 'prod'} : {},
config,
'POST',
{
checkResponse: async (response): Promise<YoroiNftModerationStatus> => {
if (response.status === 202) {
return 'pending'
}
const json = await response.json()
const status = json?.status
const parsedStatus = parseModerationStatus(status)
if (parsedStatus) {
return parsedStatus
}
throw new Error(`Invalid server response "${status}"`)
},
},
)
}

export const getTokenInfo = async (tokenId: string, apiUrl: string, config: BackendConfig): Promise<TokenInfo> => {
const nftPromise = getNFT(tokenId, config).then((nft) => {
if (!nft) throw new Error('NFT not found')
return nft
})

const tokenPromise = checkedFetch({
endpoint: `${apiUrl}/${toTokenSubject(tokenId)}`,
method: 'GET',
payload: undefined,
})
.then((response) => (response ? parseTokenRegistryEntry(response) : null))
.then((entry) => (entry ? tokenInfo(entry) : null))
.then((token) => {
if (!token) throw new Error('Token not found')
return token
})

try {
const result = await promiseAny<TokenInfo>([nftPromise, tokenPromise])
return result ?? fallbackTokenInfo(tokenId)
} catch (e) {
return fallbackTokenInfo(tokenId)
}
}

export const getFundInfo = (config: BackendConfig, isMainnet: boolean): Promise<FundInfoResponse> => {
const prefix = isMainnet ? '' : 'api/'
return fetchDefault(`${prefix}v0/catalyst/fundInfo/`, null, config, 'GET') as any
Expand All @@ -205,93 +104,3 @@ export const fetchCurrentPrice = async (currency: CurrencySymbol, config: Backen

return response.ticker.prices[currency]
}

// Token Registry
// 721: https://github.com/cardano-foundation/cardano-token-registry#semantic-content-of-registry-entries
export type TokenRegistryEntry = {
subject: string
name: Property<string>

description?: Property<string>
policy?: string
logo?: Property<string>
ticker?: Property<string>
url?: Property<string>
decimals?: Property<number>
}

type Signature = {
publicKey: string
signature: string
}

type Property<T> = {
signatures: Array<Signature>
sequenceNumber: number
value: T | undefined
}

const parseTokenRegistryEntry = (data: unknown) => {
return isTokenRegistryEntry(data) ? data : undefined
}

const isTokenRegistryEntry = (data: unknown): data is TokenRegistryEntry => {
const candidate = data as TokenRegistryEntry

return (
!!candidate &&
typeof candidate === 'object' &&
'subject' in candidate &&
typeof candidate.subject === 'string' &&
'name' in candidate &&
!!candidate.name &&
typeof candidate.name === 'object' &&
'value' in candidate.name &&
typeof candidate.name.value === 'string'
)
}

export const parseModerationStatus = (status: unknown): YoroiNftModerationStatus | undefined => {
const statusString = String(status)
const map = {
RED: 'blocked',
YELLOW: 'consent',
GREEN: 'approved',
PENDING: 'pending',
MANUAL_REVIEW: 'manual_review',
} as const
return map[statusString.toUpperCase() as keyof typeof map]
}

function parseNFTs(value: unknown, storageUrl: string): TokenInfo[] {
if (!isRecord(value)) {
throw new Error('Invalid response. Expected to receive object when parsing NFTs')
}

const identifiers = Object.keys(value)

const tokens: Array<TokenInfo | null> = identifiers.map((id) => {
const assets = value[id]
if (!isArray(assets)) {
return null
}

const nftAsset = assets.find(isAssetNFT)

if (!nftAsset) {
return null
}

const [policyId, shortName] = id.split('.')
const metadata = nftAsset.metadata?.[policyId]?.[shortName]
return convertNft({metadata, storageUrl, policyId, shortName: shortName})
})

return tokens.filter(isNonNullable)
}

function isAssetNFT(asset: unknown): asset is NFTAsset {
return isObject(asset) && hasProperties(asset, ['key']) && asset.key === NFT_METADATA_KEY
}

const NFT_METADATA_KEY = '721'
35 changes: 35 additions & 0 deletions apps/wallet-mobile/src/yoroi-wallets/cardano/api/assetSuply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {z} from 'zod'

import {BackendConfig} from '../../types'
import {createTypeGuardFromSchema} from '../../utils'
import fetchDefault from './fetch'
import {toAssetName, toPolicyId} from './utils'

export const fetchTokensSupplies = async (
tokenIds: string[],
config: BackendConfig,
): Promise<Record<string, number | null>> => {
const assets = tokenIds.map((tokenId) => ({policy: toPolicyId(tokenId), name: toAssetName(tokenId) || ''}))
const response = await fetchDefault<unknown>('multiAsset/supply', {assets}, config)

if (!isAssetSupplyEntry(response)) {
return {}
}

const supplies = assets.map((asset) => {
const key = `${asset.policy}.${asset.name}`
return response.supplies[key] || null
})

return Object.fromEntries(tokenIds.map((tokenId, index) => [tokenId, supplies[index]]))
}

type AssetSupplyEntry = {
supplies: {[key: string]: number | null}
}

const AssetSupplySchema: z.ZodSchema<AssetSupplyEntry> = z.object({
supplies: z.record(z.number().nullable()),
})

export const isAssetSupplyEntry = createTypeGuardFromSchema(AssetSupplySchema)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {isAssetSupplyEntry} from './assetSuply'

describe('isAssetSupplyEntry', () => {
it('returns true if object has supplies key of type object', () => {
expect(isAssetSupplyEntry({supplies: {}})).toEqual(true)
expect(isAssetSupplyEntry({supplies: 1})).toEqual(false)
expect(isAssetSupplyEntry({supplies: []})).toEqual(false)
expect(isAssetSupplyEntry({supplies: null})).toEqual(false)
expect(isAssetSupplyEntry({supplies: undefined})).toEqual(false)
expect(isAssetSupplyEntry({supplies: true})).toEqual(false)
})

it('returns false if not given object with supplies key', () => {
expect(isAssetSupplyEntry(null)).toEqual(false)
expect(isAssetSupplyEntry(1)).toEqual(false)
expect(isAssetSupplyEntry([])).toEqual(false)
expect(isAssetSupplyEntry(true)).toEqual(false)
expect(isAssetSupplyEntry('hello')).toEqual(false)
expect(isAssetSupplyEntry(undefined)).toEqual(false)
expect(isAssetSupplyEntry({})).toEqual(false)
})

it('requires supply value to be a number or null', () => {
expect(isAssetSupplyEntry({supplies: {a: 1}})).toEqual(true)
expect(isAssetSupplyEntry({supplies: {a: null}})).toEqual(true)
expect(isAssetSupplyEntry({supplies: {a: '1'}})).toEqual(false)
expect(isAssetSupplyEntry({supplies: {a: true}})).toEqual(false)
})
})
55 changes: 55 additions & 0 deletions apps/wallet-mobile/src/yoroi-wallets/cardano/api/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {TokenInfo} from '../../types'
import {parseNFTs} from './metadata'

const storageUrl = 'https://example.com'
describe('parseNFTs', () => {
it('throws when given a value that is not an object', () => {
expect(() => parseNFTs(null, storageUrl)).toThrow()
expect(() => parseNFTs(1, storageUrl)).toThrow()
expect(() => parseNFTs([], storageUrl)).toThrow()
expect(() => parseNFTs(true, storageUrl)).toThrow()
expect(() => parseNFTs('hello', storageUrl)).toThrow()
expect(() => parseNFTs(undefined, storageUrl)).toThrow()
})

it('returns empty array given an object that does not have an array for a value', () => {
expect(parseNFTs({policyId: 1}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: 'world'}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: null}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: true}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: {}}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: undefined}, storageUrl)).toEqual([])
})

it('returns empty array if no assets have key 721', () => {
expect(parseNFTs({policyId: []}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: [{}]}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: [{key: 'hello'}]}, storageUrl)).toEqual([])
expect(parseNFTs({policyId: [{key: 'hello'}, {key: 'world'}]}, storageUrl)).toEqual([])
})

it('resolves with placeholder data if key 721 is present and metadata is not', () => {
const result = parseNFTs({'8e2c7604711faef7c84c91b286c7327d17df825b7f0c88ec0332c0b4.0': [{key: '721'}]}, storageUrl)
const expectedValue: Partial<TokenInfo> = {
id: '8e2c7604711faef7c84c91b286c7327d17df825b7f0c88ec0332c0b4.30',
name: '0',
}
expect(result[0]).toEqual(expect.objectContaining(expectedValue))
})

it('resolves with NFT when key and metadata are present', () => {
const result = parseNFTs(
{
'8e2c7604711faef7c84c91b286c7327d17df825b7f0c88ec0332c0b4.0': [
{key: '721', metadata: {'8e2c7604711faef7c84c91b286c7327d17df825b7f0c88ec0332c0b4': {0: {name: 'Name'}}}},
],
},
storageUrl,
)
const expectedValue: Partial<TokenInfo> = {
id: '8e2c7604711faef7c84c91b286c7327d17df825b7f0c88ec0332c0b4.30',
name: 'Name',
}
expect(result[0]).toEqual(expect.objectContaining(expectedValue))
})
})

0 comments on commit f58294b

Please sign in to comment.