From d888d24aa96b252258dcf62d56396cea034d374e Mon Sep 17 00:00:00 2001 From: Mariano Goldman Date: Mon, 22 Jul 2024 17:24:36 -0300 Subject: [PATCH] feat: new approach to mappings (#282) Co-authored-by: LautaroPetaccio --- .github/workflows/build-and-publish.yml | 6 +- .github/workflows/master.yml | 8 +- .github/workflows/pr.yml | 8 +- .nvmrc | 2 +- package.json | 1 + report/schemas.api.md | 75 +++- src/platform/item/emote/emote.ts | 9 +- src/platform/item/index.ts | 2 +- src/platform/item/linked-wearable-mappings.ts | 366 ++++++++++++++++++ src/platform/item/linked-wearable-props.ts | 175 --------- src/platform/item/third-party-props.ts | 40 +- src/platform/item/wearable/wearable.ts | 13 +- .../item/linked-wearable-mappings.spec.ts | 231 +++++++++++ .../item/linked-wearable-props.spec.ts | 73 ---- .../item/wearable/linked-wearable.spec.ts | 44 +-- .../item/wearable/representation.spec.ts | 2 +- test/platform/item/wearable/wearable.spec.ts | 35 +- 17 files changed, 759 insertions(+), 331 deletions(-) create mode 100644 src/platform/item/linked-wearable-mappings.ts delete mode 100644 src/platform/item/linked-wearable-props.ts create mode 100644 test/platform/item/linked-wearable-mappings.spec.ts delete mode 100644 test/platform/item/linked-wearable-props.spec.ts diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 344bf8c4..b839fdd4 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -11,7 +11,7 @@ jobs: outputs: s3_bucket_key: ${{ steps.publish_package.outputs.s3-bucket-key }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: install run: npm ci - name: build @@ -52,7 +52,7 @@ jobs: name: Deployment Notification steps: - name: Find Comment - uses: peter-evans/find-comment@v2 + uses: peter-evans/find-comment@v3 id: fc with: issue-number: ${{ github.event.pull_request.number }} @@ -64,7 +64,7 @@ jobs: run: echo "body=${{ secrets.SDK_TEAM_S3_BASE_URL }}/${{ needs.check_and_build.outputs.s3_bucket_key }}" >> $GITHUB_OUTPUT - name: Create or update comment - uses: peter-evans/create-or-update-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 1f2e6871..17fbfc4b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -14,11 +14,11 @@ jobs: install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Use Node.js 16.x - uses: actions/setup-node@v1 + - uses: actions/checkout@v4 + - name: Use Node.js 18.x + uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: 18.x - name: npm ci run: npm ci diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 88e7db4a..ccf3b94a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,11 +10,11 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Use Node.js 16.x - uses: actions/setup-node@v1 + - uses: actions/checkout@v4 + - name: Use Node.js 18.x + uses: actions/setup-node@v4 with: - node-version: 16.x + node-version: 18.x - name: npm ci run: npm ci - name: npm run build diff --git a/.nvmrc b/.nvmrc index 5dbac1ed..3f430af8 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.13.0 \ No newline at end of file +v18 diff --git a/package.json b/package.json index c00d74fe..2a076258 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "license": "Apache-2.0", "scripts": { "build": "tsc -p tsconfig.json", + "build:watch": "tsc -p tsconfig.json --watch", "test": "mocha", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", diff --git a/report/schemas.api.md b/report/schemas.api.md index 74f83978..ec14eab7 100644 --- a/report/schemas.api.md +++ b/report/schemas.api.md @@ -72,6 +72,22 @@ export enum AccountSortBy { // @public (undocumented) type Actions = typeof SCENE_UPDATE | typeof UPDATE; +// Warning: (ae-missing-release-tag) "AddMappingError" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class AddMappingError extends Error { + // Warning: (ae-incompatible-release-tags) The symbol "__constructor" is marked as @public, but its signature references "Mapping" which is marked as @alpha + constructor(message: string, existingMapping: Mapping, conflictingMapping: Mapping); + // Warning: (ae-incompatible-release-tags) The symbol "conflictingMapping" is marked as @public, but its signature references "Mapping" which is marked as @alpha + // + // (undocumented) + conflictingMapping: Mapping; + // Warning: (ae-incompatible-release-tags) The symbol "existingMapping" is marked as @public, but its signature references "Mapping" which is marked as @alpha + // + // (undocumented) + existingMapping: Mapping; +} + export { Ajv } // Warning: (ae-missing-release-tag) "AnalyticsDayData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -643,6 +659,9 @@ export namespace Contract { validate: ValidateFunction; } +// @alpha +export type ContractAddress = string; + // Warning: (ae-missing-release-tag) "ContractFilters" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -651,6 +670,18 @@ export type ContractFilters = { network?: Network; }; +// @alpha +export enum ContractNetwork { + // (undocumented) + AMOY = "amoy", + // (undocumented) + MAINNET = "mainnet", + // (undocumented) + MATIC = "matic", + // (undocumented) + SEPOLIA = "sepolia" +} + // Warning: (ae-missing-release-tag) "ContractSortBy" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -659,6 +690,12 @@ export enum ContractSortBy { NAME = "name" } +// Warning: (ae-incompatible-release-tags) The symbol "createMappingsHelper" is marked as @public, but its signature references "Mappings" which is marked as @alpha +// Warning: (ae-missing-release-tag) "createMappingsHelper" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createMappingsHelper(initial?: Mappings): MappingsHelper; + // Warning: (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative // // @public @deprecated @@ -1381,6 +1418,34 @@ export namespace Mapping { validate: ValidateFunction; } +// @alpha +export type Mappings = Partial>>; + +// @alpha +export namespace Mappings { + const // (undocumented) + _isMappingsValid: { + keyword: string; + validate: (schema: boolean, data: any) => boolean; + errors: boolean; + }; + const // (undocumented) + innerSchema: JSONSchema>; + const // (undocumented) + schema: JSONSchema; + const // (undocumented) + validate: ValidateFunction; +} + +// Warning: (ae-missing-release-tag) "MappingsHelper" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MappingsHelper = { + getMappings(): Mappings; + addMapping(network: ContractNetwork, contractAddress: ContractAddress, mapping: Mapping): void; + includesNft(network: ContractNetwork, contractAddress: ContractAddress, tokenId: string): boolean; +}; + // @alpha export enum MappingType { // (undocumented) @@ -2925,7 +2990,7 @@ export type SyncDeployment = SnapshotSyncDeployment | PointerChangesSyncDeployme export type ThirdPartyProps = { merkleProof: MerkleProof; content: Record; - mappings?: Mapping[]; + mappings?: Mappings; }; // Warning: (ae-missing-release-tag) "Trade" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -3322,8 +3387,14 @@ export namespace WorldConfiguration { // src/platform/events/blockchain.ts:21:3 - (ae-forgotten-export) The symbol "BidMetadata" needs to be exported by the entry point index.d.ts // src/platform/events/blockchain.ts:163:3 - (ae-forgotten-export) The symbol "RentalMetadata" needs to be exported by the entry point index.d.ts // src/platform/item/emote/adr74/emote-data-adr74.ts:7:3 - (ae-incompatible-release-tags) The symbol "representations" is marked as @public, but its signature references "EmoteRepresentationADR74" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:253:3 - (ae-incompatible-release-tags) The symbol "getMappings" is marked as @public, but its signature references "Mappings" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:254:3 - (ae-incompatible-release-tags) The symbol "addMapping" is marked as @public, but its signature references "ContractNetwork" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:254:3 - (ae-incompatible-release-tags) The symbol "addMapping" is marked as @public, but its signature references "ContractAddress" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:254:3 - (ae-incompatible-release-tags) The symbol "addMapping" is marked as @public, but its signature references "Mapping" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:255:3 - (ae-incompatible-release-tags) The symbol "includesNft" is marked as @public, but its signature references "ContractNetwork" which is marked as @alpha +// src/platform/item/linked-wearable-mappings.ts:255:3 - (ae-incompatible-release-tags) The symbol "includesNft" is marked as @public, but its signature references "ContractAddress" which is marked as @alpha // src/platform/item/third-party-props.ts:7:3 - (ae-incompatible-release-tags) The symbol "merkleProof" is marked as @public, but its signature references "MerkleProof" which is marked as @alpha -// src/platform/item/third-party-props.ts:9:3 - (ae-incompatible-release-tags) The symbol "mappings" is marked as @public, but its signature references "Mapping" which is marked as @alpha +// src/platform/item/third-party-props.ts:9:3 - (ae-incompatible-release-tags) The symbol "mappings" is marked as @public, but its signature references "Mappings" which is marked as @alpha // src/platform/scene/feature-toggles.ts:11:3 - (ae-forgotten-export) The symbol "EnabledDisabled" needs to be exported by the entry point index.d.ts // src/platform/scene/feature-toggles.ts:12:3 - (ae-forgotten-export) The symbol "PortableExperiencesToggles" needs to be exported by the entry point index.d.ts // src/platform/scene/spawn-point.ts:6:3 - (ae-forgotten-export) The symbol "SinglePosition" needs to be exported by the entry point index.d.ts diff --git a/src/platform/item/emote/emote.ts b/src/platform/item/emote/emote.ts index 11bb983d..57dcf9b7 100644 --- a/src/platform/item/emote/emote.ts +++ b/src/platform/item/emote/emote.ts @@ -1,8 +1,8 @@ -import { isThirdParty, RangeMapping } from '..' +import { isThirdParty, Mappings, RangeMapping } from '..' import { generateLazyValidator, JSONSchema } from '../../../validation' import { BaseItem, baseItemProperties, isBaseEmote, requiredBaseItemProps } from '../base-item' import { standardProperties, StandardProps } from '../standard-props' -import { thirdPartyProps, ThirdPartyProps } from '../third-party-props' +import { schema as thirdPartyPropsSchema, ThirdPartyProps } from '../third-party-props' import { EmoteDataADR74 } from './adr74/emote-data-adr74' export type EmoteADR74 = BaseItem & (StandardProps | ThirdPartyProps) & { emoteDataADR74: EmoteDataADR74 } @@ -17,7 +17,7 @@ export namespace Emote { properties: { ...baseItemProperties, ...standardProperties, - ...thirdPartyProps, + ...thirdPartyPropsSchema.properties, emoteDataADR74: EmoteDataADR74.schema }, additionalProperties: true, @@ -80,6 +80,7 @@ export namespace Emote { export const validate = generateLazyValidator(schema, [ _isThirdPartyKeywordDef, _isBaseEmoteKeywordDef, - RangeMapping._fromLessThanOrEqualTo + RangeMapping._fromLessThanOrEqualTo, + Mappings._isMappingsValid ]) } diff --git a/src/platform/item/index.ts b/src/platform/item/index.ts index ed6fb620..efa507b4 100644 --- a/src/platform/item/index.ts +++ b/src/platform/item/index.ts @@ -4,6 +4,6 @@ export { Metrics } from './metrics' export { BodyShape } from './body-shape' export { StandardProps, isStandard } from './standard-props' export { ThirdPartyProps, isThirdParty } from './third-party-props' -export * from './linked-wearable-props' +export * from './linked-wearable-mappings' export * from './wearable' export * from './emote' diff --git a/src/platform/item/linked-wearable-mappings.ts b/src/platform/item/linked-wearable-mappings.ts new file mode 100644 index 00000000..995192ab --- /dev/null +++ b/src/platform/item/linked-wearable-mappings.ts @@ -0,0 +1,366 @@ +import { generateLazyValidator, JSONSchema, ValidateFunction } from '../../validation' +import { KeywordDefinition } from 'ajv' +import { ThirdPartyProps } from './third-party-props' + +/** + * MappingType + * @alpha + */ +export enum MappingType { + SINGLE = 'single', + ANY = 'any', + MULTIPLE = 'multiple', + RANGE = 'range' +} + +/** + * Mapping + * @alpha + */ +export type Mapping = SingleMapping | AnyMapping | RangeMapping | MultipleMapping + +/** + * Network + * @alpha + */ +export enum ContractNetwork { + MAINNET = 'mainnet', + MATIC = 'matic', + SEPOLIA = 'sepolia', + AMOY = 'amoy' +} + +/** + * ContractAddress + * @alpha + */ +export type ContractAddress = string + +/** + * Mappings + * @alpha + */ +export type Mappings = Partial>> + +/** + * SingleMapping + * @alpha + */ +export type SingleMapping = { + type: MappingType.SINGLE + id: string +} + +/** + * AnyMapping + * @alpha + */ +export type AnyMapping = { + type: MappingType.ANY +} + +/** + * RangeMapping + * @alpha + */ +export type RangeMapping = { + type: MappingType.RANGE + from: string + to: string +} + +/** + * MultipleMapping + * @alpha + */ +export type MultipleMapping = { + type: MappingType.MULTIPLE + ids: string[] +} + +/** + * SingleMapping + * @alpha + */ +export namespace SingleMapping { + export const schema: JSONSchema = { + type: 'object', + properties: { + type: { type: 'string', const: MappingType.SINGLE }, + id: { type: 'string', pattern: '^[0-9]+$' } + }, + required: ['type', 'id'], + additionalProperties: false + } + + export const validate: ValidateFunction = generateLazyValidator(schema) +} + +/** + * AnyMapping + * @alpha + */ +export namespace AnyMapping { + export const schema: JSONSchema = { + type: 'object', + properties: { + type: { type: 'string', const: MappingType.ANY } + }, + required: ['type'], + additionalProperties: false + } + + export const validate: ValidateFunction = generateLazyValidator(schema) +} + +/** + * RangeMapping + * @alpha + */ +export namespace RangeMapping { + export const _fromLessThanOrEqualTo: KeywordDefinition = { + keyword: '_fromLessThanOrEqualTo', + validate: function validate(schema: boolean, data: any) { + if (!data || !data.from || !data.to) { + return false + } + + let { to, from } = data + if (typeof from !== 'bigint' || typeof to !== 'bigint') { + from = BigInt(from) + to = BigInt(to) + } + + return from <= to + }, + errors: false + } + + export const schema: JSONSchema = { + type: 'object', + properties: { + type: { type: 'string', const: MappingType.RANGE }, + from: { type: 'string', pattern: '^[0-9]+$' }, + to: { type: 'string', pattern: '^[0-9]+$' } + }, + required: ['type', 'from', 'to'], + additionalProperties: false, + _fromLessThanOrEqualTo: true + } + + export const validate: ValidateFunction = generateLazyValidator(schema, [_fromLessThanOrEqualTo]) +} + +/** + * MultipleMapping + * @alpha + */ +export namespace MultipleMapping { + export const schema: JSONSchema = { + type: 'object', + properties: { + type: { type: 'string', const: MappingType.MULTIPLE }, + ids: { + type: 'array', + items: { + type: 'string', + pattern: '^[0-9]+$' + }, + minItems: 1, + uniqueItems: true + } + }, + required: ['type', 'ids'], + additionalProperties: false + } + export const validate: ValidateFunction = generateLazyValidator(schema) +} + +/** + * Mapping + * @alpha + */ +export namespace Mapping { + export const schema: JSONSchema = { + type: 'object', + properties: { + type: { + type: 'string', + enum: Object.values(MappingType) + } + }, + required: ['type'], + oneOf: [SingleMapping.schema, AnyMapping.schema, RangeMapping.schema, MultipleMapping.schema] + } + + export const validate: ValidateFunction = generateLazyValidator(schema, [ + RangeMapping._fromLessThanOrEqualTo + ]) +} + +/** + * Mappings + * @alpha + */ +export namespace Mappings { + export const _isMappingsValid = { + keyword: '_isMappingsValid', + validate: function (schema: boolean, data: any) { + const itemAsThirdParty = data as ThirdPartyProps + try { + createMappingsHelper(itemAsThirdParty.mappings) + } catch (_) { + return false + } + return true + }, + errors: false + } + + export const innerSchema: JSONSchema> = { + type: 'object', + patternProperties: { + '^0x[0-9a-fA-F]{40}$': { + type: 'array', + items: Mapping.schema + } + }, + minProperties: 1, + required: [], + additionalProperties: false, + _isMappingsValid: true + } + + const properties = Object.values(ContractNetwork).reduce((acc, network) => { + acc[network] = innerSchema + return acc + }, {} as any) + + export const schema: JSONSchema = { + type: 'object', + properties, + minProperties: 1, + additionalProperties: false + } + + export const validate: ValidateFunction = generateLazyValidator(schema, [ + RangeMapping._fromLessThanOrEqualTo, + _isMappingsValid + ]) +} + +export type MappingsHelper = { + getMappings(): Mappings + addMapping(network: ContractNetwork, contractAddress: ContractAddress, mapping: Mapping): void + includesNft(network: ContractNetwork, contractAddress: ContractAddress, tokenId: string): boolean +} + +export class AddMappingError extends Error { + constructor(message: string, public existingMapping: Mapping, public conflictingMapping: Mapping) { + super(message) + } +} + +export function createMappingsHelper(initial: Mappings = {}): MappingsHelper { + const mappings: Mappings = {} + + for (const [network, contracts] of Object.entries(initial)) { + mappings[network as ContractNetwork] = {} + for (const [contractAddress, mappingsArray] of Object.entries(contracts)) { + for (const mapping of mappingsArray) { + // We add them this way to make sure we build a valid object + addMapping(network as ContractNetwork, contractAddress, mapping) + } + } + } + + function getMappings(): Mappings { + return JSON.parse(JSON.stringify(mappings)) + } + + function overlappingCheck(mapping: Mapping, other: Mapping): boolean { + switch (mapping.type) { + case MappingType.SINGLE: + switch (other.type) { + case MappingType.SINGLE: + return mapping.id === other.id + case MappingType.ANY: + return true + case MappingType.MULTIPLE: + return other.ids.includes(mapping.id) + case MappingType.RANGE: + return BigInt(mapping.id) >= BigInt(other.from) && BigInt(mapping.id) <= BigInt(other.to) + } + + case MappingType.ANY: + return true + + case MappingType.MULTIPLE: + switch (other.type) { + case MappingType.SINGLE: + return mapping.ids.includes(other.id) + case MappingType.ANY: + return true + case MappingType.MULTIPLE: + return mapping.ids.some((id) => other.ids.includes(id)) + case MappingType.RANGE: + return mapping.ids.some((id) => BigInt(id) >= BigInt(other.from) && BigInt(id) <= BigInt(other.to)) + } + + case MappingType.RANGE: + switch (other.type) { + case MappingType.SINGLE: + return BigInt(other.id) >= BigInt(mapping.from) && BigInt(other.id) <= BigInt(mapping.to) + case MappingType.ANY: + return true + case MappingType.MULTIPLE: + return other.ids.some((id) => BigInt(id) >= BigInt(mapping.from) && BigInt(id) <= BigInt(mapping.to)) + case MappingType.RANGE: + return BigInt(mapping.from) <= BigInt(other.to) && BigInt(mapping.to) >= BigInt(other.from) + } + } + } + + function addMapping(network: ContractNetwork, contractAddress: ContractAddress, mapping: Mapping) { + const lowerContractAddress = contractAddress.toLowerCase() + mappings[network] = mappings[network] ?? {} + mappings[network]![lowerContractAddress] = mappings[network]![lowerContractAddress] ?? [] + + for (const existingMapping of mappings[network]![lowerContractAddress]) { + if (overlappingCheck(existingMapping, mapping)) { + throw new AddMappingError( + `Cannot add mapping to contract ${lowerContractAddress} on network ${network} because it overlaps with existing mapping`, + existingMapping, + mapping + ) + } + } + + mappings[network]![lowerContractAddress].push(mapping) + } + + function includesNft(network: ContractNetwork, contractAddress: ContractAddress, tokenId: string): boolean { + if (!mappings[network]?.[contractAddress]) { + return false + } + + return mappings[network]![contractAddress].some((mapping) => { + switch (mapping.type) { + case MappingType.SINGLE: + return mapping.id === tokenId + case MappingType.ANY: + return true + case MappingType.MULTIPLE: + return mapping.ids.includes(tokenId) + case MappingType.RANGE: + return BigInt(tokenId) >= BigInt(mapping.from) && BigInt(tokenId) <= BigInt(mapping.to) + } + }) + } + + return { + addMapping, + getMappings, + includesNft + } +} diff --git a/src/platform/item/linked-wearable-props.ts b/src/platform/item/linked-wearable-props.ts deleted file mode 100644 index 8edb736c..00000000 --- a/src/platform/item/linked-wearable-props.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { generateLazyValidator, JSONSchema, ValidateFunction } from '../../validation' -import { KeywordDefinition } from 'ajv' - -/** - * MappingType - * @alpha - */ -export enum MappingType { - SINGLE = 'single', - ANY = 'any', - MULTIPLE = 'multiple', - RANGE = 'range' -} - -/** - * Mapping - * @alpha - */ -export type Mapping = SingleMapping | AnyMapping | RangeMapping | MultipleMapping - -/** - * SingleMapping - * @alpha - */ -export type SingleMapping = { - type: MappingType.SINGLE - id: string -} - -/** - * AnyMapping - * @alpha - */ -export type AnyMapping = { - type: MappingType.ANY -} - -/** - * RangeMapping - * @alpha - */ -export type RangeMapping = { - type: MappingType.RANGE - from: string - to: string -} - -/** - * MultipleMapping - * @alpha - */ -export type MultipleMapping = { - type: MappingType.MULTIPLE - ids: string[] -} - -/** - * SingleMapping - * @alpha - */ -export namespace SingleMapping { - export const schema: JSONSchema = { - type: 'object', - properties: { - type: { type: 'string', const: MappingType.SINGLE }, - id: { type: 'string', pattern: '^[0-9]+$' } - }, - required: ['type', 'id'], - additionalProperties: false - } - - export const validate: ValidateFunction = generateLazyValidator(schema) -} - -/** - * AnyMapping - * @alpha - */ -export namespace AnyMapping { - export const schema: JSONSchema = { - type: 'object', - properties: { - type: { type: 'string', const: MappingType.ANY } - }, - required: ['type'], - additionalProperties: false - } - - export const validate: ValidateFunction = generateLazyValidator(schema) -} - -/** - * RangeMapping - * @alpha - */ -export namespace RangeMapping { - export const _fromLessThanOrEqualTo: KeywordDefinition = { - keyword: '_fromLessThanOrEqualTo', - validate: function validate(schema: boolean, data: any) { - if (!data || !data.from || !data.to) { - return false - } - - let { to, from } = data - if (typeof from !== 'bigint' || typeof to !== 'bigint') { - from = BigInt(from) - to = BigInt(to) - } - - return from <= to - }, - errors: false - } - - export const schema: JSONSchema = { - type: 'object', - properties: { - type: { type: 'string', const: MappingType.RANGE }, - from: { type: 'string', pattern: '^[0-9]+$' }, - to: { type: 'string', pattern: '^[0-9]+$' } - }, - required: ['type', 'from', 'to'], - additionalProperties: false, - _fromLessThanOrEqualTo: true - } - - export const validate: ValidateFunction = generateLazyValidator(schema, [_fromLessThanOrEqualTo]) -} - -/** - * MultipleMapping - * @alpha - */ -export namespace MultipleMapping { - export const schema: JSONSchema = { - type: 'object', - properties: { - type: { type: 'string', const: MappingType.MULTIPLE }, - ids: { - type: 'array', - items: { - type: 'string', - pattern: '^[0-9]+$' - }, - minItems: 1, - uniqueItems: true - } - }, - required: ['type', 'ids'], - additionalProperties: false - } - export const validate: ValidateFunction = generateLazyValidator(schema) -} - -/** - * Mappings - * @alpha - */ -export namespace Mapping { - export const schema: JSONSchema = { - type: 'object', - properties: { - type: { - type: 'string', - enum: Object.values(MappingType) - } - }, - required: ['type'], - oneOf: [SingleMapping.schema, AnyMapping.schema, RangeMapping.schema, MultipleMapping.schema] - } - - export const validate: ValidateFunction = generateLazyValidator(schema, [ - RangeMapping._fromLessThanOrEqualTo - ]) -} diff --git a/src/platform/item/third-party-props.ts b/src/platform/item/third-party-props.ts index aad2f348..4da8b664 100644 --- a/src/platform/item/third-party-props.ts +++ b/src/platform/item/third-party-props.ts @@ -1,35 +1,28 @@ import { generateLazyValidator, JSONSchema, ValidateFunction } from '../../validation' import { MerkleProof } from '../merkle-tree' import { BaseItem } from './base-item' -import { Mapping, RangeMapping } from './linked-wearable-props' +import { Mappings, RangeMapping } from './linked-wearable-mappings' export type ThirdPartyProps = { merkleProof: MerkleProof content: Record - mappings?: Mapping[] + mappings?: Mappings } -export const thirdPartyProps = { - merkleProof: MerkleProof.schema, - content: { - type: 'object', - nullable: false, - additionalProperties: { type: 'string' }, - required: [] as any[] - }, - mappings: { - type: 'array', - items: Mapping.schema, - minItems: 1, - maxItems: 1, - nullable: true - } -} as const - -const schema: JSONSchema = { +export const schema: JSONSchema = { type: 'object', properties: { - ...thirdPartyProps + merkleProof: MerkleProof.schema, + content: { + type: 'object', + nullable: false, + additionalProperties: { type: 'string' }, + required: [] as any[] + }, + mappings: { + nullable: true, + ...Mappings.schema + } }, required: ['merkleProof', 'content'], _containsHashingKeys: true @@ -37,7 +30,7 @@ const schema: JSONSchema = { const _containsHashingKeys = { keyword: '_containsHashingKeys', - validate: (schema: boolean, data: any) => { + validate: function (schema: boolean, data: any) { const itemAsThirdParty = data as ThirdPartyProps if (itemAsThirdParty?.merkleProof?.hashingKeys) { return itemAsThirdParty.merkleProof.hashingKeys.every((key) => itemAsThirdParty.hasOwnProperty(key)) @@ -49,7 +42,8 @@ const _containsHashingKeys = { const validate: ValidateFunction = generateLazyValidator(schema, [ _containsHashingKeys, - RangeMapping._fromLessThanOrEqualTo + RangeMapping._fromLessThanOrEqualTo, + Mappings._isMappingsValid ]) export function isThirdParty(item: T): item is T & ThirdPartyProps { diff --git a/src/platform/item/wearable/wearable.ts b/src/platform/item/wearable/wearable.ts index 6e9cc8fe..8f9f8dd6 100644 --- a/src/platform/item/wearable/wearable.ts +++ b/src/platform/item/wearable/wearable.ts @@ -3,9 +3,9 @@ import { WearableCategory } from './wearable-category' import { WearableRepresentation } from './representation' import { BaseItem, baseItemProperties, isBaseAvatar, requiredBaseItemProps } from '../base-item' import { StandardProps, standardProperties } from '../standard-props' -import { isThirdParty, ThirdPartyProps, thirdPartyProps } from '../third-party-props' +import { isThirdParty, ThirdPartyProps, schema as thirdPartyPropsSchema } from '../third-party-props' import { HideableWearableCategory } from './hideable-category' -import { RangeMapping } from '../linked-wearable-props' +import { Mappings, RangeMapping } from '../linked-wearable-mappings' /** @alpha */ export type Wearable = BaseItem & { @@ -27,7 +27,7 @@ export namespace Wearable { properties: { ...baseItemProperties, ...standardProperties, - ...thirdPartyProps, + ...thirdPartyPropsSchema.properties.thirdPartyProps, data: { type: 'object', properties: { @@ -70,12 +70,12 @@ export namespace Wearable { oneOf: [ { required: ['id', 'i18n'], - prohibited: ['merkleProof', 'content', 'collectionAddress', 'rarity'], + prohibited: ['merkleProof', 'content', 'collectionAddress', 'rarity', 'mappings'], _isBaseAvatar: true }, { required: [...requiredBaseItemProps, 'data', 'collectionAddress', 'rarity'], - prohibited: ['merkleProof', 'content'] + prohibited: ['merkleProof', 'content', 'mappings'] }, { required: [ @@ -119,6 +119,7 @@ export namespace Wearable { export const validate = generateLazyValidator(schema, [ _isThirdPartyKeywordDef, _isBaseAvatarKeywordDef, - RangeMapping._fromLessThanOrEqualTo + RangeMapping._fromLessThanOrEqualTo, + Mappings._isMappingsValid ]) } diff --git a/test/platform/item/linked-wearable-mappings.spec.ts b/test/platform/item/linked-wearable-mappings.spec.ts new file mode 100644 index 00000000..cefd8530 --- /dev/null +++ b/test/platform/item/linked-wearable-mappings.spec.ts @@ -0,0 +1,231 @@ +import expect from 'expect' +import { expectValidationFailureWithErrors, testTypeSignature } from '../../test-utils' +import { + AnyMapping, + ContractNetwork, + createMappingsHelper, + Mapping, + Mappings, + MappingsHelper, + MappingType, + MultipleMapping, + RangeMapping, + SingleMapping +} from '../../../src' + +const mappings: Mappings = { + [ContractNetwork.MATIC]: { + '0x1234567890123456789012345678901234567890': [ + { type: MappingType.SINGLE, id: '1' }, + { type: MappingType.MULTIPLE, ids: ['5', '7'] }, + { type: MappingType.RANGE, from: '10', to: '19' } + ] + } +} + +const singleMapping: SingleMapping = { + type: MappingType.SINGLE, + id: '1' +} + +const allMapping: AnyMapping = { + type: MappingType.ANY +} + +const multipleMapping: MultipleMapping = { + type: MappingType.MULTIPLE, + ids: ['1', '3'] +} + +const rangeMapping: RangeMapping = { + type: MappingType.RANGE, + from: '1', + to: '9' +} + +describe('Third Party Mappings tests', () => { + testTypeSignature(Mappings, mappings) + + it('static tests must pass', () => { + expect(Mappings.validate(mappings)).toEqual(true) + expect(Mappings.validate(null)).toEqual(false) + expectValidationFailureWithErrors(Mappings.validate, {}, ['must NOT have fewer than 1 properties']) + }) +}) + +describe('Third Party tests - each individual mapping type', () => { + testTypeSignature(Mapping, singleMapping) + + it('static tests must pass', () => { + expect(Mapping.validate(singleMapping)).toEqual(true) + expect(Mapping.validate(null)).toEqual(false) + expect(Mapping.validate({})).toEqual(false) + }) + + describe('no extra properties allowed', () => { + const testFn = ({ mapping, expected }: { mapping: any; expected: boolean }) => + function () { + expect(Mapping.validate({ ...mapping, extra: 'extra' })).toEqual(expected) + } + + it('for type: single', testFn({ mapping: singleMapping, expected: false })) + it('for type: multiple', testFn({ mapping: multipleMapping, expected: false })) + it('for type: all', testFn({ mapping: allMapping, expected: false })) + it('for type: range', testFn({ mapping: rangeMapping, expected: false })) + }) + + it('static tests must return the correct errors when missing properties', () => { + expect(Mapping.validate({})).toEqual(false) + expect(Mapping.validate({ type: MappingType.SINGLE })).toEqual(false) + expect(Mapping.validate({ type: MappingType.MULTIPLE })).toEqual(false) + expect(Mapping.validate({ type: MappingType.RANGE })).toEqual(false) + expect(Mapping.validate({ type: MappingType.ANY })).toEqual(true) + }) + + it('range mapping with from greater than to fails validation', () => { + expect( + Mapping.validate({ + type: MappingType.RANGE, + from: '11', + to: '1' + }) + ).toEqual(false) + }) + + it('multiple mapping with duplicate ids fails validation', () => { + expect( + Mapping.validate({ + type: MappingType.MULTIPLE, + ids: ['1', '3', '1'] + }) + ).toEqual(false) + }) + + describe('mapping helper', () => { + let helper: MappingsHelper + + beforeEach(() => { + helper = createMappingsHelper() + }) + + it('should add a single mapping', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping) + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '1')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '2')).toBeFalsy() + expect(helper.includesNft(ContractNetwork.MATIC, '0x123', '2')).toBeFalsy() + }) + + it('should add an all mapping', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping) + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '1')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '9834')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MATIC, '0x123', '2')).toBeFalsy() + }) + + it('should add a multiple mapping', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping) + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '1')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '2')).toBeFalsy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '3')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MATIC, '0x123', '2')).toBeFalsy() + }) + + it('should add a range mapping', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping) + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '1')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '2')).toBeTruthy() + expect(helper.includesNft(ContractNetwork.MAINNET, '0x123', '10')).toBeFalsy() + expect(helper.includesNft(ContractNetwork.MATIC, '0x123', '2')).toBeFalsy() + }) + + describe('should fail to add overlapping mappings', () => { + describe('single with', () => { + it('single', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping)).toThrowError() + }) + + it('all', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping)).toThrowError() + }) + + it('multiple', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping)).toThrowError() + }) + + it('range', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping)).toThrowError() + }) + }) + + describe('all with', () => { + it('single', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping)).toThrowError() + }) + + it('all', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping)).toThrowError() + }) + + it('multiple', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping)).toThrowError() + }) + + it('range', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping)).toThrowError() + }) + }) + + describe('multiple with', () => { + it('single', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping)).toThrowError() + }) + + it('all', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping)).toThrowError() + }) + + it('multiple', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping)).toThrowError() + }) + + it('range', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping)).toThrowError() + }) + }) + + describe('range with', () => { + it('single', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', singleMapping)).toThrowError() + }) + + it('all', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', allMapping)).toThrowError() + }) + + it('multiple', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', multipleMapping)).toThrowError() + }) + + it('range', () => { + helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping) + expect(() => helper.addMapping(ContractNetwork.MAINNET, '0x123', rangeMapping)).toThrowError() + }) + }) + }) + }) +}) diff --git a/test/platform/item/linked-wearable-props.spec.ts b/test/platform/item/linked-wearable-props.spec.ts deleted file mode 100644 index db927a21..00000000 --- a/test/platform/item/linked-wearable-props.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import expect from 'expect' -import { testTypeSignature } from '../../test-utils' -import { AnyMapping, Mapping, MultipleMapping, RangeMapping, SingleMapping } from '../../../src' -import { MappingType } from '../../../src' - -const singleMapping: SingleMapping = { - type: MappingType.SINGLE, - id: '1' -} - -const allMapping: AnyMapping = { - type: MappingType.ANY -} - -const multipleMapping: MultipleMapping = { - type: MappingType.MULTIPLE, - ids: ['1', '3'] -} - -const rangeMapping: RangeMapping = { - type: MappingType.RANGE, - from: '1', - to: '9' -} - -describe('Linked Wearable Props tests', () => { - testTypeSignature(Mapping, singleMapping) - - it('static tests must pass', () => { - expect(Mapping.validate(singleMapping)).toEqual(true) - expect(Mapping.validate(null)).toEqual(false) - expect(Mapping.validate({})).toEqual(false) - }) - - describe('no extra properties allowed', () => { - const testFn = ({ mapping, expected }: { mapping: any; expected: boolean }) => - function () { - expect(Mapping.validate({ ...mapping, extra: 'extra' })).toEqual(expected) - } - - it('for type: single', testFn({ mapping: singleMapping, expected: false })) - it('for type: multiple', testFn({ mapping: multipleMapping, expected: false })) - it('for type: all', testFn({ mapping: allMapping, expected: false })) - it('for type: range', testFn({ mapping: rangeMapping, expected: false })) - }) - - it('static tests must return the correct errors when missing properties', () => { - expect(Mapping.validate({})).toEqual(false) - expect(Mapping.validate({ type: MappingType.SINGLE })).toEqual(false) - expect(Mapping.validate({ type: MappingType.MULTIPLE })).toEqual(false) - expect(Mapping.validate({ type: MappingType.RANGE })).toEqual(false) - expect(Mapping.validate({ type: MappingType.ANY })).toEqual(true) - }) - - it('range mapping with from greater than to fails validation', () => { - expect( - Mapping.validate({ - type: MappingType.RANGE, - from: '11', - to: '1' - }) - ).toEqual(false) - }) - - it('multiple mapping with duplicate ids fails validation', () => { - expect( - Mapping.validate({ - type: MappingType.MULTIPLE, - ids: ['1', '3', '1'] - }) - ).toEqual(false) - }) -}) diff --git a/test/platform/item/wearable/linked-wearable.spec.ts b/test/platform/item/wearable/linked-wearable.spec.ts index a483dc88..df8e9a22 100644 --- a/test/platform/item/wearable/linked-wearable.spec.ts +++ b/test/platform/item/wearable/linked-wearable.spec.ts @@ -2,15 +2,16 @@ import expect from 'expect' import { BodyPartCategory, BodyShape, + ContractNetwork, isThirdParty, Locale, + MappingType, ThirdPartyProps, Wearable, WearableCategory, WearableRepresentation } from '../../../../src' import { expectValidationFailureWithErrors, testTypeSignature } from '../../../test-utils' -import { MappingType } from '../../../../src' describe('Linked wearables tests', () => { const representation: WearableRepresentation = { @@ -72,12 +73,16 @@ describe('Linked wearables tests', () => { hashingKeys: ['id', 'name', 'description', 'i18n', 'image', 'thumbnail', 'data', 'content', 'mappings'], entityHash: '52c312f5e5524739388af971cddb526c3b49ba31ec77abc07ca01f5b113f1eba' }, - mappings: [ - { - type: MappingType.SINGLE, - id: '0' + mappings: { + [ContractNetwork.MAINNET]: { + '0x1234567890123456789012345678901234567890': [ + { + type: MappingType.SINGLE, + id: '0' + } + ] } - ] + } } const linkedWearable = { ...baseWearable, ...thirdParty } @@ -119,30 +124,11 @@ describe('Linked wearables tests', () => { Wearable.validate, { ...linkedWearable, - mappings: [] - }, - ['must NOT have fewer than 1 items'] - ) - }) - - // For now we will support only one mapping type - it('wearable with more than one mapping fails', () => { - expectValidationFailureWithErrors( - Wearable.validate, - { - ...linkedWearable, - mappings: [ - { - type: MappingType.MULTIPLE, - ids: [1, 2] - }, - { - type: MappingType.SINGLE, - id: 3 - } - ] + mappings: { + bitcoin: {} // Makes third party properties invalid + } }, - ['must NOT have more than 1 items'] + ['either standard XOR thirdparty properties conditions must be met'] ) }) }) diff --git a/test/platform/item/wearable/representation.spec.ts b/test/platform/item/wearable/representation.spec.ts index 50281965..f95dbaf9 100644 --- a/test/platform/item/wearable/representation.spec.ts +++ b/test/platform/item/wearable/representation.spec.ts @@ -1,5 +1,5 @@ import expect from 'expect' -import { BodyShape, WearableRepresentation } from '../../../../src/platform/item' +import { BodyShape, WearableRepresentation } from '../../../../src' import { testTypeSignature } from '../../../test-utils' describe('Wearable representation tests', () => { diff --git a/test/platform/item/wearable/wearable.spec.ts b/test/platform/item/wearable/wearable.spec.ts index dd3c1ca5..a82046d0 100644 --- a/test/platform/item/wearable/wearable.spec.ts +++ b/test/platform/item/wearable/wearable.spec.ts @@ -1,8 +1,17 @@ import expect from 'expect' -import { Rarity, WearableCategory } from '../../../../src' -import { BodyShape, Wearable, WearableRepresentation, Locale, BodyPartCategory } from '../../../../src' -import { isStandard } from '../../../../src' -import { isThirdParty } from '../../../../src' +import { + BodyPartCategory, + BodyShape, + ContractNetwork, + isStandard, + isThirdParty, + Locale, + MappingType, + Rarity, + Wearable, + WearableCategory, + WearableRepresentation +} from '../../../../src' import { expectValidationFailureWithErrors, testTypeSignature } from '../../../test-utils' describe('Wearable representation tests', () => { @@ -73,10 +82,26 @@ describe('Wearable representation tests', () => { } const wearable = { ...baseWearable, ...standard } - const thirdPartyWearable = { ...baseWearable, ...thirdParty } + const thirdPartyWearable = { + ...baseWearable, + ...thirdParty + } testTypeSignature(Wearable, wearable) testTypeSignature(Wearable, thirdPartyWearable) + testTypeSignature(Wearable, { + ...baseWearable, + ...thirdParty, + mappings: { + [ContractNetwork.MAINNET]: { + '0x1234567890123456789012345678901234567890': [ + { + type: MappingType.ANY + } + ] + } + } + }) it('static base wearable must puss', () => { expect(