Skip to content

Commit

Permalink
feat: list third party collections (#1651)
Browse files Browse the repository at this point in the history
* feat: list third party collections

* feat: remove urn join method

* feat: urn tests

* feat: missing test and rename
  • Loading branch information
nicosantangelo authored Nov 4, 2021
1 parent 3294714 commit a65ed26
Show file tree
Hide file tree
Showing 13 changed files with 284 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'
import { Button, Card, Confirm } from 'decentraland-ui'
import classNames from 'classnames'
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
import { isThirdParty } from 'modules/collection/utils'
import CollectionStatus from 'components/CollectionStatus'
import CollectionImage from 'components/CollectionImage'
import { locations } from 'routing/locations'
Expand Down Expand Up @@ -39,7 +40,10 @@ const CollectionCard = (props: Props & CollectedProps) => {
<div className="text" title={collection.name}>
{collection.name} <CollectionStatus collection={collection} />
</div>
<div className="subtitle">{t('collection_card.subtitle', { count: items.length })}</div>
<div className="subtitle">
{isThirdParty(collection) ? t('collection_card.third_party_collection') : t('collection_card.collection')}&nbsp;·&nbsp;
{t('collection_card.item_count', { count: items.length })}
</div>
</Card.Content>
</Link>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from 'decentraland-ui'
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
import Modal from 'decentraland-dapps/dist/containers/Modal'
import { join } from 'lib/urn'
import { buildThirdPartyURN, decodeURN } from 'lib/urn'
import { Collection, COLLECTION_NAME_MAX_LENGTH } from 'modules/collection/types'
import { Props, State } from './CreateThirdPartyCollectionModal.types'

Expand All @@ -33,12 +33,13 @@ export default class CreateThirdPartyCollectionModal extends React.PureComponent
if (collectionName && urnSuffix) {
const now = Date.now()
const thirdParty = this.getSelectedThirdParty()
const decodedURN = decodeURN(thirdParty.id)

const collection: Collection = {
id: uuid.v4(),
name: collectionName,
owner: address!,
urn: join(thirdParty.id, urnSuffix),
urn: buildThirdPartyURN(decodedURN.suffix, urnSuffix),
isPublished: false,
isApproved: false,
minters: [],
Expand Down
118 changes: 108 additions & 10 deletions src/lib/urn.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChainId, Network } from '@dcl/schemas'
import { getChainIdByNetwork } from 'decentraland-dapps/dist/lib/eth'
import { buildItemURN, getCatalystItemURN, pop, join } from './urn'
import { buildItemURN, buildThirdPartyURN, buildCatalystItemURN, decodeURN, URNType, URNProtocol } from './urn'

jest.mock('decentraland-dapps/dist/lib/eth')

Expand All @@ -15,28 +15,126 @@ describe('when building the item URN', () => {
})

describe('when getting the catalyst item URN', () => {
let contractAddress = '0x123123'
let tokenId = 'token-id'

beforeEach(() => {
;(getChainIdByNetwork as jest.Mock).mockReturnValueOnce(ChainId.MATIC_MAINNET)
})

it('should use the supplied data to generate a valid item URN', () => {
expect(getCatalystItemURN('0x123123', 'token-id')).toBe('urn:decentraland:matic:collections-v2:0x123123:token-id')
expect(buildCatalystItemURN(contractAddress, tokenId)).toBe('urn:decentraland:matic:collections-v2:0x123123:token-id')
})

it('should get the chain id for the matic network', () => {
buildCatalystItemURN(contractAddress, tokenId)
expect(getChainIdByNetwork).toHaveBeenCalledWith(Network.MATIC)
})
})

describe('when popping the last URN section', () => {
it('should get the last URN section', () => {
expect(pop('this:is:some:urn:getme')).toBe('getme')
describe('when building the third party URN', () => {
let thirdPartyName = 'some-tp-name'
let collectionId = 'the-collection-id'

beforeEach(() => {
;(getChainIdByNetwork as jest.Mock).mockReturnValueOnce(ChainId.MATIC_MAINNET)
})

it('should return a valid third party collection urn', () => {
expect(buildThirdPartyURN(thirdPartyName, collectionId)).toBe(
'urn:decentraland:matic:collections-thirdparty:some-tp-name:the-collection-id'
)
})

it('should get the chain id for the matic network', () => {
buildThirdPartyURN(thirdPartyName, collectionId)
expect(getChainIdByNetwork).toHaveBeenCalledWith(Network.MATIC)
})

describe('when supplying a token id', () => {
let tokenId = 'a-wonderful-token-id'

it('should return a valid third party item urn', () => {
expect(buildThirdPartyURN(thirdPartyName, collectionId, tokenId)).toBe(
'urn:decentraland:matic:collections-thirdparty:some-tp-name:the-collection-id:a-wonderful-token-id'
)
})

it('should get the chain id for the matic network', () => {
buildThirdPartyURN(thirdPartyName, collectionId, tokenId)
expect(getChainIdByNetwork).toHaveBeenCalledWith(Network.MATIC)
})
})
})

describe('join', () => {
it('should join all parts into a URN', () => {
expect(join('this', 'is', 'new')).toBe('this:is:new')
describe('when decoding an URN', () => {
describe('when the URN is invalid', () => {
it('should throw an error', () => {
let urn = 'invalid things here'
expect(() => decodeURN(urn)).toThrow('Invalid URN: "invalid things here"')
})
})

describe('when a valid base avatar urn is used', () => {
it('should decode and return each group', () => {
expect(decodeURN('urn:decentraland:off-chain:base-avatars:BaseMale')).toEqual({
type: URNType.BASE_AVATARS,
protocol: URNProtocol.OFF_CHAIN,
suffix: 'BaseMale'
})
})
})

it('should return an empty string if no arg is supplied', () => {
expect(join()).toBe('')
describe('when a valid collection v2 urn is used', () => {
it('should decode and return each group', () => {
expect(decodeURN('urn:decentraland:ropsten:collections-v2:0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8')).toEqual({
type: URNType.COLLECTIONS_V2,
protocol: URNProtocol.ROPSTEN,
suffix: '0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8'
})
})
})

describe('when a valid third party', () => {
let thirdPartyRecordURN = 'urn:decentraland:matic:collections-thirdparty:crypto-motors'

describe('when third party record urn is used', () => {
it('should decode and return each group', () => {
expect(decodeURN(thirdPartyRecordURN)).toEqual({
type: URNType.COLLECTIONS_THIRDPARTY,
protocol: URNProtocol.MATIC,
suffix: 'crypto-motors',
thirdPartyName: 'crypto-motors',
thirdPartyCollectionId: undefined,
thirdPartyTokenId: undefined
})
})
})

describe('when third party collection urn is used', () => {
it('should decode and return each group', () => {
expect(decodeURN(thirdPartyRecordURN + ':tp-collection-id')).toEqual({
type: URNType.COLLECTIONS_THIRDPARTY,
protocol: URNProtocol.MATIC,
suffix: 'crypto-motors:tp-collection-id',
thirdPartyName: 'crypto-motors',
thirdPartyCollectionId: 'tp-collection-id',
thirdPartyTokenId: undefined
})
})
})

describe('when a third party item urn is used', () => {
it('should decode and return each group', () => {
expect(decodeURN(thirdPartyRecordURN + ':another-tp-collection-id:better-token-id')).toEqual({
type: URNType.COLLECTIONS_THIRDPARTY,
protocol: URNProtocol.MATIC,
suffix: 'crypto-motors:another-tp-collection-id:better-token-id',
thirdPartyName: 'crypto-motors',
thirdPartyCollectionId: 'another-tp-collection-id',
thirdPartyTokenId: 'better-token-id'
})
})
})
})
})
101 changes: 91 additions & 10 deletions src/lib/urn.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,106 @@
import { getURNProtocol, Network } from '@dcl/schemas'
import { getChainIdByNetwork } from 'decentraland-dapps/dist/lib/eth'

export const VERSION = 1
export const DELIMITER = ':'
const VERSION = 1

export function buildItemURN(type: string, name: string, description: string, category: string, bodyShapeTypes: string) {
/**
* urn:decentraland:
* (?<protocol>
* mainnet|
* ropsten|
* matic|
* mumbai|
* off-chain
* ):
* (
* (?<type>
* base-avatars|
* collections-v2|
* collections-thirdparty
* ):
* (?<suffix>
* ((?<=base-avatars:)BaseMale|BaseFemale)|
* ((?<=collections-v2)0x[a-fA-F0-9]{40})|
* ((?<=collections-thirdparty:)
* (?<thirdPartyName>[^:|\\s]+)
* (:(?<thirdPartyCollectionId>[^:|\\s]+))?
* (:(?<thirdPartyTokenId>[^:|\\s]+))?
* )
* )
* )
*/
const baseMatcher = 'urn:decentraland'
const protocolMatcher = '(?<protocol>mainnet|ropsten|matic|mumbai|off-chain)'
const typeMatcher = '(?<type>base-avatars|collections-v2|collections-thirdparty)'

const baseAvatarsSuffixMatcher = '((?<=base-avatars:)BaseMale|BaseFemale)'
const collectionsSuffixMatcher = '((?<=collections-v2:)0x[a-fA-F0-9]{40})'
const thirdPartySuffixMatcher =
'((?<=collections-thirdparty:)(?<thirdPartyName>[^:|\\s]+)(:(?<thirdPartyCollectionId>[^:|\\s]+))?(:(?<thirdPartyTokenId>[^:|\\s]+))?)'

const urnRegExp = new RegExp(
`${baseMatcher}:${protocolMatcher}:${typeMatcher}:(?<suffix>${baseAvatarsSuffixMatcher}|${collectionsSuffixMatcher}|${thirdPartySuffixMatcher})`
)

export enum URNProtocol {
MAINNET = 'mainnet',
ROPSTEN = 'ropsten',
MATIC = 'matic',
MUMBAI = 'mumbai',
OFF_CHAIN = 'off-chain'
}
export enum URNType {
BASE_AVATARS = 'base-avatars',
COLLECTIONS_V2 = 'collections-v2',
COLLECTIONS_THIRDPARTY = 'collections-thirdparty'
}
export type URN = string

type BaseDecodedURN = {
protocol: URNProtocol
suffix: string
}
export type DecodedURN = BaseDecodedURN &
(
| { type: URNType.BASE_AVATARS | URNType.COLLECTIONS_V2 }
| {
type: URNType.COLLECTIONS_THIRDPARTY
thirdPartyName: string
thirdPartyCollectionId?: string
thirdPartyTokenId?: string
}
)

export function buildItemURN(type: string, name: string, description: string, category: string, bodyShapeTypes: string): URN {
return `${VERSION}:${type[0]}:${name}:${description}:${category}:${bodyShapeTypes}`
}

export function getCatalystItemURN(contractAddress: string, tokenId: string) {
return `urn:decentraland:${getURNProtocol(getChainIdByNetwork(Network.MATIC))}:collections-v2:${contractAddress}:${tokenId}`
export function buildThirdPartyURN(thirdPartyName: string, collectionId: string, tokenId?: string) {
let urn = `urn:decentraland:${getNetworkURNProtocol(Network.MATIC)}:collections-thirdparty:${thirdPartyName}:${collectionId}`
if (tokenId) {
urn += `:${tokenId}`
}
return urn
}

export function buildCatalystItemURN(contractAddress: string, tokenId: string): URN {
return `urn:decentraland:${getNetworkURNProtocol(Network.MATIC)}:collections-v2:${contractAddress}:${tokenId}`
}

export function toLegacyURN(urn: string) {
export function toLegacyURN(urn: URN): URN {
return urn.replace('urn:decentraland:off-chain:base-avatars:', 'dcl://base-avatars/')
}

export function join(...args: string[]) {
return args.join(DELIMITER)
export function decodeURN(urn: URN): DecodedURN {
const matches = urnRegExp.exec(urn)

if (!matches || !matches.groups) {
throw new Error(`Invalid URN: "${urn}"`)
}

return matches.groups as DecodedURN
}

export function pop(urn: string) {
return urn.split(DELIMITER).pop()
function getNetworkURNProtocol(network: Network) {
return getURNProtocol(getChainIdByNetwork(network))
}
53 changes: 51 additions & 2 deletions src/modules/collection/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Item } from 'modules/item/types'
import { ChainId } from '@dcl/schemas'
import * as dappsEth from 'decentraland-dapps/dist/lib/eth'
import { buildCatalystItemURN, buildThirdPartyURN } from 'lib/urn'
import { Item, WearableBodyShape } from 'modules/item/types'
import { Collection } from 'modules/collection/types'
import { Mint } from './types'
import { getTotalAmountOfMintedItems, isLocked } from './utils'
import { getTotalAmountOfMintedItems, isLocked, isThirdParty } from './utils'

describe('when counting the amount of minted items', () => {
let mints: Mint[]
Expand Down Expand Up @@ -85,3 +88,49 @@ describe('when checking collection locks', () => {
})
})
})

describe('when checking if a collection is a third party', () => {
let collection: Collection

describe('when the collection lacks a URN', () => {
beforeEach(() => {
collection = { id: 'aCollection' } as Collection
})

it('should return false', () => {
expect(isThirdParty(collection)).toBe(false)
})
})

describe('when the collection has a base avatar URN', () => {
beforeEach(() => {
collection = { id: 'aCollection', urn: WearableBodyShape.FEMALE.toString() } as Collection
})

it('should return false', () => {
expect(isThirdParty(collection)).toBe(false)
})
})

describe('when the collection has a collections v2 URN', () => {
beforeEach(() => {
jest.spyOn(dappsEth, 'getChainIdByNetwork').mockReturnValueOnce(ChainId.MATIC_MAINNET)
collection = { id: 'aCollection', urn: buildCatalystItemURN('0xc6d2000a7a1ddca92941f4e2b41360fe4ee2abd8', '22') } as Collection
})

it('should return false', () => {
expect(isThirdParty(collection)).toBe(false)
})
})

describe('when the collection has a third party URN', () => {
beforeEach(() => {
jest.spyOn(dappsEth, 'getChainIdByNetwork').mockReturnValueOnce(ChainId.MATIC_MAINNET)
collection = { id: 'aCollection', urn: buildThirdPartyURN('thirdpartyname', 'collection-id', '22') } as Collection
})

it('should return true', () => {
expect(isThirdParty(collection)).toBe(true)
})
})
})
9 changes: 9 additions & 0 deletions src/modules/collection/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ContractName, getContract } from 'decentraland-transactions'
import { Wallet } from 'decentraland-dapps/dist/modules/wallet/types'
import { Item, SyncStatus } from 'modules/item/types'
import { isEqual, includes } from 'lib/address'
import { decodeURN, URNType } from 'lib/urn'
import { Collection, Access, Mint } from './types'
import { locations } from 'routing/locations'

Expand Down Expand Up @@ -95,3 +96,11 @@ export function getMostRelevantStatus(statusA: SyncStatus, statusB: SyncStatus)
const indexB = sorted.indexOf(statusB)
return indexA < indexB ? statusA : statusB
}

export function isThirdParty(collection: Collection) {
if (!collection.urn) {
return false
}
const decodedURN = decodeURN(collection.urn)
return decodedURN.type === URNType.COLLECTIONS_THIRDPARTY
}
Loading

1 comment on commit a65ed26

@vercel
Copy link

@vercel vercel bot commented on a65ed26 Nov 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.