From 047498c29c0b137511c17011d7d4ddf4b8b6d722 Mon Sep 17 00:00:00 2001 From: shuse2 Date: Mon, 8 May 2023 13:23:59 +0200 Subject: [PATCH 01/58] Add template files for NFT module (#8423) * :seedling: Add template files * :bug: Fix test template --- .../src/modules/nft/cc_commands/.gitkeep | 0 framework/src/modules/nft/cc_method.ts | 25 ++++++++ framework/src/modules/nft/commands/.gitkeep | 0 framework/src/modules/nft/constants.ts | 13 ++++ framework/src/modules/nft/endpoint.ts | 25 ++++++++ framework/src/modules/nft/events/.gitkeep | 0 framework/src/modules/nft/index.ts | 16 +++++ framework/src/modules/nft/internal_method.ts | 25 ++++++++ framework/src/modules/nft/method.ts | 37 ++++++++++++ framework/src/modules/nft/module.ts | 60 +++++++++++++++++++ framework/src/modules/nft/schemas.ts | 13 ++++ framework/src/modules/nft/stores/.gitkeep | 0 framework/src/modules/nft/types.ts | 39 ++++++++++++ .../test/unit/modules/nft/module.spec.ts | 18 ++++++ 14 files changed, 271 insertions(+) create mode 100644 framework/src/modules/nft/cc_commands/.gitkeep create mode 100644 framework/src/modules/nft/cc_method.ts create mode 100644 framework/src/modules/nft/commands/.gitkeep create mode 100644 framework/src/modules/nft/constants.ts create mode 100644 framework/src/modules/nft/endpoint.ts create mode 100644 framework/src/modules/nft/events/.gitkeep create mode 100644 framework/src/modules/nft/index.ts create mode 100644 framework/src/modules/nft/internal_method.ts create mode 100644 framework/src/modules/nft/method.ts create mode 100644 framework/src/modules/nft/module.ts create mode 100644 framework/src/modules/nft/schemas.ts create mode 100644 framework/src/modules/nft/stores/.gitkeep create mode 100644 framework/src/modules/nft/types.ts create mode 100644 framework/test/unit/modules/nft/module.spec.ts diff --git a/framework/src/modules/nft/cc_commands/.gitkeep b/framework/src/modules/nft/cc_commands/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/framework/src/modules/nft/cc_method.ts b/framework/src/modules/nft/cc_method.ts new file mode 100644 index 00000000000..91c38ea11ce --- /dev/null +++ b/framework/src/modules/nft/cc_method.ts @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCCMethod } from '../interoperability/base_cc_method'; +import { InteroperabilityMethod } from './types'; + +export class NFTInteroperableMethod extends BaseCCMethod { + // @ts-expect-error TODO: unused error. Remove when implementing. + private _interopMethod!: InteroperabilityMethod; + + public addDependencies(interoperabilityMethod: InteroperabilityMethod) { + this._interopMethod = interoperabilityMethod; + } +} diff --git a/framework/src/modules/nft/commands/.gitkeep b/framework/src/modules/nft/commands/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts new file mode 100644 index 00000000000..206ba71de27 --- /dev/null +++ b/framework/src/modules/nft/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ diff --git a/framework/src/modules/nft/endpoint.ts b/framework/src/modules/nft/endpoint.ts new file mode 100644 index 00000000000..aa2637fa295 --- /dev/null +++ b/framework/src/modules/nft/endpoint.ts @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { ModuleConfig } from './types'; +import { BaseEndpoint } from '../base_endpoint'; + +export class NFTEndpoint extends BaseEndpoint { + // @ts-expect-error TODO: unused error. Remove when implementing. + private _moduleConfig!: ModuleConfig; + + public init(moduleConfig: ModuleConfig) { + this._moduleConfig = moduleConfig; + } +} diff --git a/framework/src/modules/nft/events/.gitkeep b/framework/src/modules/nft/events/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/framework/src/modules/nft/index.ts b/framework/src/modules/nft/index.ts new file mode 100644 index 00000000000..14063d827fe --- /dev/null +++ b/framework/src/modules/nft/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export { NFTModule } from './module'; +export { NFTMethod } from './method'; diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts new file mode 100644 index 00000000000..28c3ed9e09c --- /dev/null +++ b/framework/src/modules/nft/internal_method.ts @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseMethod } from '../base_method'; +import { ModuleConfig } from './types'; + +export class InternalMethod extends BaseMethod { + // @ts-expect-error TODO: unused error. Remove when implementing. + private _config!: ModuleConfig; + + public init(config: ModuleConfig): void { + this._config = config; + } +} diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts new file mode 100644 index 00000000000..16b611a5766 --- /dev/null +++ b/framework/src/modules/nft/method.ts @@ -0,0 +1,37 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ +import { BaseMethod } from '../base_method'; +import { InteroperabilityMethod, ModuleConfig } from './types'; +import { InternalMethod } from './internal_method'; + +export class NFTMethod extends BaseMethod { + // @ts-expect-error TODO: unused error. Remove when implementing. + private _config!: ModuleConfig; + // @ts-expect-error TODO: unused error. Remove when implementing. + private _interoperabilityMethod!: InteroperabilityMethod; + // @ts-expect-error TODO: unused error. Remove when implementing. + private _internalMethod!: InternalMethod; + + public init(config: ModuleConfig): void { + this._config = config; + } + + public addDependencies( + interoperabilityMethod: InteroperabilityMethod, + internalMethod: InternalMethod, + ) { + this._interoperabilityMethod = interoperabilityMethod; + this._internalMethod = internalMethod; + } +} diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts new file mode 100644 index 00000000000..55e24c14fc8 --- /dev/null +++ b/framework/src/modules/nft/module.ts @@ -0,0 +1,60 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { GenesisBlockExecuteContext } from '../../state_machine'; +import { ModuleInitArgs, ModuleMetadata } from '../base_module'; +import { BaseInteroperableModule } from '../interoperability'; +import { InteroperabilityMethod } from '../token/types'; +import { NFTInteroperableMethod } from './cc_method'; +import { NFTEndpoint } from './endpoint'; +import { InternalMethod } from './internal_method'; +import { NFTMethod } from './method'; +import { FeeMethod } from './types'; + +export class NFTModule extends BaseInteroperableModule { + public method = new NFTMethod(this.stores, this.events); + public endpoint = new NFTEndpoint(this.stores, this.offchainStores); + public crossChainMethod = new NFTInteroperableMethod(this.stores, this.events); + + private readonly _internalMethod = new InternalMethod(this.stores, this.events); + // @ts-expect-error TODO: unused error. Remove when implementing. + private _interoperabilityMethod!: InteroperabilityMethod; + + public commands = []; + + // eslint-disable-next-line no-useless-constructor + public constructor() { + super(); + } + + public addDependencies(interoperabilityMethod: InteroperabilityMethod, _feeMethod: FeeMethod) { + this._interoperabilityMethod = interoperabilityMethod; + this.method.addDependencies(interoperabilityMethod, this._internalMethod); + this.crossChainMethod.addDependencies(interoperabilityMethod); + } + + public metadata(): ModuleMetadata { + return { + ...this.baseMetadata(), + endpoints: [], + assets: [], + }; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async init(_args: ModuleInitArgs) {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async initGenesisState(_context: GenesisBlockExecuteContext): Promise {} +} diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts new file mode 100644 index 00000000000..206ba71de27 --- /dev/null +++ b/framework/src/modules/nft/schemas.ts @@ -0,0 +1,13 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ diff --git a/framework/src/modules/nft/stores/.gitkeep b/framework/src/modules/nft/stores/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts new file mode 100644 index 00000000000..40fa051c2f8 --- /dev/null +++ b/framework/src/modules/nft/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { MethodContext } from '../../state_machine'; +import { CCMsg } from '../interoperability'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ModuleConfig {} + +export interface InteroperabilityMethod { + send( + methodContext: MethodContext, + feeAddress: Buffer, + module: string, + crossChainCommand: string, + receivingChainID: Buffer, + fee: bigint, + status: number, + parameters: Buffer, + timestamp?: number, + ): Promise; + error(methodContext: MethodContext, ccm: CCMsg, code: number): Promise; + terminateChain(methodContext: MethodContext, chainID: Buffer): Promise; +} + +export interface FeeMethod { + payFee(methodContext: MethodContext, amount: bigint): void; +} diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts new file mode 100644 index 00000000000..ccbc53346ba --- /dev/null +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ +describe('nft module', () => { + it('should be implemented', () => { + expect(true).toBeTrue(); + }); +}); From d702f7d279e201478485d1bc80ac091e5a1b15ab Mon Sep 17 00:00:00 2001 From: shuse2 Date: Mon, 8 May 2023 13:54:30 +0200 Subject: [PATCH 02/58] Add template files for NFT module (#8423) * :seedling: Add template files * :bug: Fix test template From 3fe5e43b51abede8e38ed902d4bae02d1144d5fa Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 26 May 2023 08:57:21 +0200 Subject: [PATCH 03/58] Adds Stores for NFT Module (#8488) * :seedling: Adds stores for NFT module * :seedling: Adds getKey method to UserStore for NFT module * :bug: Fixes required property in schema for NFT module's UserStore * :recycle: Updates parameter name for NFT module's UserStore#getKey --- framework/src/modules/nft/constants.ts | 26 +++++ framework/src/modules/nft/stores/.gitkeep | 0 framework/src/modules/nft/stores/escrow.ts | 28 +++++ framework/src/modules/nft/stores/nft.ts | 73 ++++++++++++ .../src/modules/nft/stores/supported_nfts.ts | 62 ++++++++++ framework/src/modules/nft/stores/user.ts | 42 +++++++ .../test/unit/modules/nft/stores/nft.spec.ts | 106 ++++++++++++++++++ .../modules/nft/stores/supported_nfts.spec.ts | 65 +++++++++++ .../test/unit/modules/nft/stores/user.spec.ts | 34 ++++++ 9 files changed, 436 insertions(+) delete mode 100644 framework/src/modules/nft/stores/.gitkeep create mode 100644 framework/src/modules/nft/stores/escrow.ts create mode 100644 framework/src/modules/nft/stores/nft.ts create mode 100644 framework/src/modules/nft/stores/supported_nfts.ts create mode 100644 framework/src/modules/nft/stores/user.ts create mode 100644 framework/test/unit/modules/nft/stores/nft.spec.ts create mode 100644 framework/test/unit/modules/nft/stores/supported_nfts.spec.ts create mode 100644 framework/test/unit/modules/nft/stores/user.spec.ts diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index 206ba71de27..37f82833ed3 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -11,3 +11,29 @@ * * Removal or modification of this copyright notice is prohibited. */ + +export const LENGTH_CHAIN_ID = 4; +export const LENGTH_NFT_ID = 16; +export const LENGTH_COLLECTION_ID = 4; +export const MIN_LENGTH_MODULE_NAME = 1; +export const MAX_LENGTH_MODULE_NAME = 32; +export const LENGTH_ADDRESS = 20; + +export const enum NftEventResult { + SUCCESSFUL = 0, + NFT_DOES_NOT_EXIST = 1, + NFT_NOT_NATIVE = 2, + NFT_NOT_SUPPORTED = 3, + NFT_LOCKED = 4, + NFT_NOT_LOCKED = 5, + UNAUTHORIZED_UNLOCK = 6, + NFT_ESCROWED = 7, + NFT_NOT_ESCROWED = 8, + INITIATED_BY_NONNATIVE_CHAIN = 9, + INITIATED_BY_NONOWNER = 10, + RECOVER_FAIL_INVALID_INPUTS = 11, + INSUFFICIENT_BALANCE = 12, + DATA_TOO_LONG = 13, +} + +export type NFTErrorEventResult = Exclude; diff --git a/framework/src/modules/nft/stores/.gitkeep b/framework/src/modules/nft/stores/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/framework/src/modules/nft/stores/escrow.ts b/framework/src/modules/nft/stores/escrow.ts new file mode 100644 index 00000000000..719bf0b7fbe --- /dev/null +++ b/framework/src/modules/nft/stores/escrow.ts @@ -0,0 +1,28 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseStore } from '../../base_store'; + +export const escrowStoreSchema = { + $id: '/nft/store/escrow', + type: 'object', + required: [], + properties: {}, +}; + +type EscrowStoreData = Record; + +export class EscrowStore extends BaseStore { + public schema = escrowStoreSchema; +} diff --git a/framework/src/modules/nft/stores/nft.ts b/framework/src/modules/nft/stores/nft.ts new file mode 100644 index 00000000000..c1e8ce23244 --- /dev/null +++ b/framework/src/modules/nft/stores/nft.ts @@ -0,0 +1,73 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseStore, StoreGetter } from '../../base_store'; +import { MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from '../constants'; + +export interface NFTStoreData { + owner: Buffer; + attributesArray: { + module: string; + attributes: Buffer; + }[]; +} + +export const nftStoreSchema = { + $id: '/nft/store/nft', + type: 'object', + required: ['owner', 'attributesArray'], + properties: { + owner: { + dataType: 'bytes', + fieldNumber: 1, + }, + attributesArray: { + type: 'array', + fieldNumber: 2, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; + +export class NFTStore extends BaseStore { + public schema = nftStoreSchema; + + public async save(context: StoreGetter, nftID: Buffer, data: NFTStoreData): Promise { + const attributesArray = data.attributesArray.filter( + attribute => attribute.attributes.length > 0, + ); + attributesArray.sort((a, b) => a.module.localeCompare(b.module, 'en')); + + await this.set(context, nftID, { + ...data, + attributesArray, + }); + } +} diff --git a/framework/src/modules/nft/stores/supported_nfts.ts b/framework/src/modules/nft/stores/supported_nfts.ts new file mode 100644 index 00000000000..e16dcb0838e --- /dev/null +++ b/framework/src/modules/nft/stores/supported_nfts.ts @@ -0,0 +1,62 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseStore, StoreGetter } from '../../base_store'; +import { LENGTH_COLLECTION_ID } from '../constants'; + +export interface SupportedNFTsStoreData { + supportedCollectionIDArray: { + collectionID: Buffer; + }[]; +} + +export const supportedNFTsStoreSchema = { + $id: '/nft/store/supportedNFTs', + type: 'object', + required: ['supportedCollectionIDArray'], + properties: { + supportedCollectionIDArray: { + type: 'array', + fieldNumber: 1, + items: { + type: 'object', + required: ['collectionID'], + properties: { + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 1, + }, + }, + }, + }, + }, +}; + +export class SupportedNFTsStore extends BaseStore { + public schema = supportedNFTsStoreSchema; + + public async save( + context: StoreGetter, + chainID: Buffer, + data: SupportedNFTsStoreData, + ): Promise { + const supportedCollectionIDArray = data.supportedCollectionIDArray.sort((a, b) => + a.collectionID.compare(b.collectionID), + ); + + await this.set(context, chainID, { supportedCollectionIDArray }); + } +} diff --git a/framework/src/modules/nft/stores/user.ts b/framework/src/modules/nft/stores/user.ts new file mode 100644 index 00000000000..752b55abf21 --- /dev/null +++ b/framework/src/modules/nft/stores/user.ts @@ -0,0 +1,42 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ +import { BaseStore } from '../../base_store'; +import { MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from '../constants'; + +export interface UserStoreData { + lockingModule: string; +} + +export const userStoreSchema = { + $id: '/nft/store/user', + type: 'object', + required: ['lockingModule'], + properties: { + lockingModule: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + }, +}; + +export class UserStore extends BaseStore { + public schema = userStoreSchema; + + public getKey(address: Buffer, nftID: Buffer): Buffer { + return Buffer.concat([address, nftID]); + } +} diff --git a/framework/test/unit/modules/nft/stores/nft.spec.ts b/framework/test/unit/modules/nft/stores/nft.spec.ts new file mode 100644 index 00000000000..7142bd787e1 --- /dev/null +++ b/framework/test/unit/modules/nft/stores/nft.spec.ts @@ -0,0 +1,106 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { NFTStore } from '../../../../../src/modules/nft/stores/nft'; +import { PrefixedStateReadWriter } from '../../../../../src/state_machine/prefixed_state_read_writer'; +import { InMemoryPrefixedStateDB } from '../../../../../src/testing'; +import { createStoreGetter } from '../../../../../src/testing/utils'; +import { LENGTH_NFT_ID } from '../../../../../src/modules/nft/constants'; +import { StoreGetter } from '../../../../../src'; + +describe('NFTStore', () => { + let store: NFTStore; + let context: StoreGetter; + + beforeEach(() => { + store = new NFTStore('NFT', 5); + + const db = new InMemoryPrefixedStateDB(); + const stateStore = new PrefixedStateReadWriter(db); + + context = createStoreGetter(stateStore); + }); + + describe('save', () => { + it('should order NFTs of an owner by module', async () => { + const nftID = Buffer.alloc(LENGTH_NFT_ID, 0); + const owner = Buffer.alloc(8, 1); + + const unsortedAttributesArray = [ + { + module: 'token', + attributes: Buffer.alloc(8, 1), + }, + { + module: 'pos', + attributes: Buffer.alloc(8, 1), + }, + ]; + + const sortedAttributesArray = [ + { + module: 'pos', + attributes: Buffer.alloc(8, 1), + }, + { + module: 'token', + attributes: Buffer.alloc(8, 1), + }, + ]; + + await store.save(context, nftID, { + owner, + attributesArray: unsortedAttributesArray, + }); + + await expect(store.get(context, nftID)).resolves.toEqual({ + owner, + attributesArray: sortedAttributesArray, + }); + }); + + it('should remove modules with no attributes array', async () => { + const nftID = Buffer.alloc(LENGTH_NFT_ID, 0); + const owner = Buffer.alloc(8, 1); + + const attributesArray = [ + { + module: 'nft', + attributes: Buffer.alloc(0), + }, + { + module: 'pos', + attributes: Buffer.alloc(8, 1), + }, + ]; + + const filteredAttributesArray = [ + { + module: 'pos', + attributes: Buffer.alloc(8, 1), + }, + ]; + + await store.save(context, nftID, { + owner, + attributesArray, + }); + + await expect(store.get(context, nftID)).resolves.toEqual({ + owner, + attributesArray: filteredAttributesArray, + }); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts b/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts new file mode 100644 index 00000000000..968cfa3bb42 --- /dev/null +++ b/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts @@ -0,0 +1,65 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { SupportedNFTsStore } from '../../../../../src/modules/nft/stores/supported_nfts'; +import { PrefixedStateReadWriter } from '../../../../../src/state_machine/prefixed_state_read_writer'; +import { InMemoryPrefixedStateDB } from '../../../../../src/testing'; +import { createStoreGetter } from '../../../../../src/testing/utils'; +import { LENGTH_COLLECTION_ID } from '../../../../../src/modules/nft/constants'; +import { CHAIN_ID_LENGTH, StoreGetter } from '../../../../../src'; + +describe('NFTStore', () => { + let store: SupportedNFTsStore; + let context: StoreGetter; + + beforeEach(() => { + store = new SupportedNFTsStore('NFT', 5); + + const db = new InMemoryPrefixedStateDB(); + const stateStore = new PrefixedStateReadWriter(db); + + context = createStoreGetter(stateStore); + }); + + describe('save', () => { + it('should order supported NFT collection of a chain', async () => { + const chainID = Buffer.alloc(CHAIN_ID_LENGTH, 0); + + const unsortedSupportedCollections = [ + { + collectionID: Buffer.alloc(LENGTH_COLLECTION_ID, 1), + }, + { + collectionID: Buffer.alloc(LENGTH_COLLECTION_ID, 0), + }, + { + collectionID: Buffer.from([0, 1, 1, 0]), + }, + ]; + + const sortedSupportedCollections = unsortedSupportedCollections.sort((a, b) => + a.collectionID.compare(b.collectionID), + ); + + const data = { + supportedCollectionIDArray: unsortedSupportedCollections, + }; + await store.save(context, chainID, data); + + await expect(store.get(context, chainID)).resolves.toEqual({ + supportedCollectionIDArray: sortedSupportedCollections, + }); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/stores/user.spec.ts b/framework/test/unit/modules/nft/stores/user.spec.ts new file mode 100644 index 00000000000..e16fcc3f52a --- /dev/null +++ b/framework/test/unit/modules/nft/stores/user.spec.ts @@ -0,0 +1,34 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { UserStore } from '../../../../../src/modules/nft/stores/user'; +import { LENGTH_ADDRESS, LENGTH_NFT_ID } from '../../../../../src/modules/nft/constants'; + +describe('UserStore', () => { + let store: UserStore; + + beforeEach(() => { + store = new UserStore('NFT', 5); + }); + + describe('getKey', () => { + it('should concatenate the provided address and nftID', () => { + const address = utils.getRandomBytes(LENGTH_ADDRESS); + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + expect(store.getKey(address, nftID)).toEqual(Buffer.concat([address, nftID])); + }); + }); +}); From a4ca7b88ff31933a6a2441b78558b81177c32648 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 26 May 2023 14:21:58 +0200 Subject: [PATCH 04/58] Adds events for NFT module (#8485) * :seedling: Adds events for NFT module * :recycle: Updates event schema names and event type * :label: Removes duplicate types * :label: Updates TransferCrossChainEventData * :label: Updates NftErrorEventResult * :bug: Updates references to NFTErrorEventResult to NftErrorEventResult * :memo: Adds event data type for AllNFTsFromChainSupportedEvent * :recycle: Updates log interface * :memo: Updates NftEventResult * :memo: Updates NftEventResult * :bug: Fixes references to NftEventResult --- framework/src/modules/nft/constants.ts | 33 ++++---- framework/src/modules/nft/events/.gitkeep | 0 .../events/all_nfts_from_chain_suported.ts | 42 ++++++++++ .../all_nfts_from_chain_support_removed.ts | 42 ++++++++++ ...ll_nfts_from_collection_support_removed.ts | 49 ++++++++++++ .../all_nfts_from_collection_suppported.ts | 49 ++++++++++++ .../nft/events/all_nfts_support_removed.ts | 21 +++++ .../modules/nft/events/all_nfts_supported.ts | 21 +++++ .../src/modules/nft/events/ccm_transfer.ts | 61 +++++++++++++++ framework/src/modules/nft/events/create.ts | 62 +++++++++++++++ framework/src/modules/nft/events/destroy.ts | 55 ++++++++++++++ framework/src/modules/nft/events/lock.ts | 61 +++++++++++++++ framework/src/modules/nft/events/recover.ts | 53 +++++++++++++ .../src/modules/nft/events/set_attributes.ts | 53 +++++++++++++ framework/src/modules/nft/events/transfer.ts | 65 ++++++++++++++++ .../nft/events/transfer_cross_chain.ts | 76 +++++++++++++++++++ framework/src/modules/nft/events/unlock.ts | 61 +++++++++++++++ framework/src/modules/nft/module.ts | 38 ++++++++++ 18 files changed, 827 insertions(+), 15 deletions(-) delete mode 100644 framework/src/modules/nft/events/.gitkeep create mode 100644 framework/src/modules/nft/events/all_nfts_from_chain_suported.ts create mode 100644 framework/src/modules/nft/events/all_nfts_from_chain_support_removed.ts create mode 100644 framework/src/modules/nft/events/all_nfts_from_collection_support_removed.ts create mode 100644 framework/src/modules/nft/events/all_nfts_from_collection_suppported.ts create mode 100644 framework/src/modules/nft/events/all_nfts_support_removed.ts create mode 100644 framework/src/modules/nft/events/all_nfts_supported.ts create mode 100644 framework/src/modules/nft/events/ccm_transfer.ts create mode 100644 framework/src/modules/nft/events/create.ts create mode 100644 framework/src/modules/nft/events/destroy.ts create mode 100644 framework/src/modules/nft/events/lock.ts create mode 100644 framework/src/modules/nft/events/recover.ts create mode 100644 framework/src/modules/nft/events/set_attributes.ts create mode 100644 framework/src/modules/nft/events/transfer.ts create mode 100644 framework/src/modules/nft/events/transfer_cross_chain.ts create mode 100644 framework/src/modules/nft/events/unlock.ts diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index 37f82833ed3..c0cbc168a98 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -20,20 +20,23 @@ export const MAX_LENGTH_MODULE_NAME = 32; export const LENGTH_ADDRESS = 20; export const enum NftEventResult { - SUCCESSFUL = 0, - NFT_DOES_NOT_EXIST = 1, - NFT_NOT_NATIVE = 2, - NFT_NOT_SUPPORTED = 3, - NFT_LOCKED = 4, - NFT_NOT_LOCKED = 5, - UNAUTHORIZED_UNLOCK = 6, - NFT_ESCROWED = 7, - NFT_NOT_ESCROWED = 8, - INITIATED_BY_NONNATIVE_CHAIN = 9, - INITIATED_BY_NONOWNER = 10, - RECOVER_FAIL_INVALID_INPUTS = 11, - INSUFFICIENT_BALANCE = 12, - DATA_TOO_LONG = 13, + RESULT_SUCCESSFUL = 0, + RESULT_NFT_DOES_NOT_EXIST = 1, + RESULT_NFT_NOT_NATIVE = 2, + RESULT_NFT_NOT_SUPPORTED = 3, + RESULT_NFT_LOCKED = 4, + RESULT_NFT_NOT_LOCKED = 5, + RESULT_UNAUTHORIZED_UNLOCK = 6, + RESULT_NFT_ESCROWED = 7, + RESULT_NFT_NOT_ESCROWED = 8, + RESULT_INITIATED_BY_NONNATIVE_CHAIN = 9, + RESULT_INITIATED_BY_NONOWNER = 10, + RESULT_RECOVER_FAIL_INVALID_INPUTS = 11, + RESULT_INSUFFICIENT_BALANCE = 12, + RESULT_DATA_TOO_LONG = 13, } -export type NFTErrorEventResult = Exclude; +export type NftErrorEventResult = Exclude< + NftEventResult, + NftEventResult.RESULT_NFT_ESCROWED | NftEventResult.RESULT_SUCCESSFUL +>; diff --git a/framework/src/modules/nft/events/.gitkeep b/framework/src/modules/nft/events/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/framework/src/modules/nft/events/all_nfts_from_chain_suported.ts b/framework/src/modules/nft/events/all_nfts_from_chain_suported.ts new file mode 100644 index 00000000000..9798ac42b9c --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_from_chain_suported.ts @@ -0,0 +1,42 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_CHAIN_ID } from '../constants'; + +export interface AllNFTsFromChainSupportedEventData { + chainID: Buffer; +} + +export const allNFTsFromChainSupportedEventSchema = { + $id: '/nft/events/allNFTsFromChainSupported', + type: 'object', + required: ['chainID'], + properties: { + chainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + }, +}; + +export class AllNFTsFromChainSupportedEvent extends BaseEvent { + public schema = allNFTsFromChainSupportedEventSchema; + + public log(ctx: EventQueuer, chainID: Buffer): void { + this.add(ctx, { chainID }, [chainID]); + } +} diff --git a/framework/src/modules/nft/events/all_nfts_from_chain_support_removed.ts b/framework/src/modules/nft/events/all_nfts_from_chain_support_removed.ts new file mode 100644 index 00000000000..0fe1603823a --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_from_chain_support_removed.ts @@ -0,0 +1,42 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_CHAIN_ID } from '../constants'; + +export interface AllNFTsFromChainSupportRemovedEventData { + chainID: Buffer; +} + +export const allNFTsFromChainSupportRemovedEventSchema = { + $id: '/nft/events/allNFTsFromChainSupportRemoved', + type: 'object', + required: ['chainID'], + properties: { + chainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + }, +}; + +export class AllNFTsFromChainSupportRemovedEvent extends BaseEvent { + public schema = allNFTsFromChainSupportRemovedEventSchema; + + public log(ctx: EventQueuer, chainID: Buffer): void { + this.add(ctx, { chainID }, [chainID]); + } +} diff --git a/framework/src/modules/nft/events/all_nfts_from_collection_support_removed.ts b/framework/src/modules/nft/events/all_nfts_from_collection_support_removed.ts new file mode 100644 index 00000000000..388ae16d9aa --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_from_collection_support_removed.ts @@ -0,0 +1,49 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID } from '../constants'; + +export interface AllNFTsFromCollectionSupportRemovedEventData { + chainID: Buffer; + collectionID: Buffer; +} + +export const allNFTsFromCollectionSupportRemovedEventSchema = { + $id: '/nft/events/allNFTsFromCollectionSupportRemoved', + type: 'object', + required: ['chainID', 'collectionID'], + properties: { + chainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + }, +}; + +export class AllNFTsFromCollectionSupportRemovedEvent extends BaseEvent { + public schema = allNFTsFromCollectionSupportRemovedEventSchema; + + public log(ctx: EventQueuer, data: AllNFTsFromCollectionSupportRemovedEventData): void { + this.add(ctx, data, [data.chainID, data.collectionID]); + } +} diff --git a/framework/src/modules/nft/events/all_nfts_from_collection_suppported.ts b/framework/src/modules/nft/events/all_nfts_from_collection_suppported.ts new file mode 100644 index 00000000000..9b82b4f1539 --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_from_collection_suppported.ts @@ -0,0 +1,49 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID } from '../constants'; + +export interface AllNFTsFromCollectionSupportedEventData { + chainID: Buffer; + collectionID: Buffer; +} + +export const allNFTsFromCollectionSupportedEventSchema = { + $id: '/nft/events/allNFTsFromCollectionSupported', + type: 'object', + required: ['chainID', 'collectionID'], + properties: { + chainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + }, +}; + +export class AllNFTsFromCollectionSupportedEvent extends BaseEvent { + public schema = allNFTsFromCollectionSupportedEventSchema; + + public log(ctx: EventQueuer, data: AllNFTsFromCollectionSupportedEventData): void { + this.add(ctx, data, [data.chainID, data.collectionID]); + } +} diff --git a/framework/src/modules/nft/events/all_nfts_support_removed.ts b/framework/src/modules/nft/events/all_nfts_support_removed.ts new file mode 100644 index 00000000000..a15f6fbe6bf --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_support_removed.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; + +export class AllNFTsSupportRemovedEvent extends BaseEvent { + public log(ctx: EventQueuer): void { + this.add(ctx, undefined); + } +} diff --git a/framework/src/modules/nft/events/all_nfts_supported.ts b/framework/src/modules/nft/events/all_nfts_supported.ts new file mode 100644 index 00000000000..80f1da06a20 --- /dev/null +++ b/framework/src/modules/nft/events/all_nfts_supported.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; + +export class AllNFTsSupportedEvent extends BaseEvent { + public log(ctx: EventQueuer): void { + this.add(ctx, undefined); + } +} diff --git a/framework/src/modules/nft/events/ccm_transfer.ts b/framework/src/modules/nft/events/ccm_transfer.ts new file mode 100644 index 00000000000..1e72b946398 --- /dev/null +++ b/framework/src/modules/nft/events/ccm_transfer.ts @@ -0,0 +1,61 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, NftEventResult } from '../constants'; + +export interface CCMTransferEventData { + senderAddress: Buffer; + recipientAddress: Buffer; + nftID: Buffer; +} + +export const ccmTransferEventSchema = { + $id: '/nft/events/ccmTransfer', + type: 'object', + required: ['senderAddress', 'recipientAddress', 'nftID', 'result'], + properties: { + senderAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 3, + }, + result: { + dataType: 'uint32', + fieldNumber: 4, + }, + }, +}; + +export class CcmTransferEvent extends BaseEvent { + public schema = ccmTransferEventSchema; + + public log(ctx: EventQueuer, data: CCMTransferEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.senderAddress, + data.recipientAddress, + ]); + } +} diff --git a/framework/src/modules/nft/events/create.ts b/framework/src/modules/nft/events/create.ts new file mode 100644 index 00000000000..c14b93d1a88 --- /dev/null +++ b/framework/src/modules/nft/events/create.ts @@ -0,0 +1,62 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_COLLECTION_ID, LENGTH_NFT_ID, NftEventResult } from '../constants'; + +export interface CreateEventData { + address: Buffer; + nftID: Buffer; + collectionID: Buffer; +} + +export const createEventSchema = { + $id: '/nft/events/create', + type: 'object', + required: ['address', 'nftID', 'collectionID', 'result'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLenght: LENGTH_NFT_ID, + fieldNumber: 2, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLenght: LENGTH_COLLECTION_ID, + fieldNumber: 3, + }, + result: { + dataType: 'uint32', + fieldNumber: 4, + }, + }, +}; + +export class CreateEvent extends BaseEvent { + public schema = createEventSchema; + + public log(ctx: EventQueuer, data: CreateEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.address, + data.nftID, + ]); + } +} diff --git a/framework/src/modules/nft/events/destroy.ts b/framework/src/modules/nft/events/destroy.ts new file mode 100644 index 00000000000..1294f466ba9 --- /dev/null +++ b/framework/src/modules/nft/events/destroy.ts @@ -0,0 +1,55 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, NftEventResult } from '../constants'; + +export interface DestroyEventData { + address: Buffer; + nftID: Buffer; +} + +export const createEventSchema = { + $id: '/nft/events/destroy', + type: 'object', + required: ['address', 'nftID', 'result'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLenght: LENGTH_NFT_ID, + fieldNumber: 2, + }, + result: { + dataType: 'uint32', + fieldNumber: 3, + }, + }, +}; + +export class DestroyEvent extends BaseEvent { + public schema = createEventSchema; + + public log(ctx: EventQueuer, data: DestroyEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.address, + data.nftID, + ]); + } +} diff --git a/framework/src/modules/nft/events/lock.ts b/framework/src/modules/nft/events/lock.ts new file mode 100644 index 00000000000..9820836158f --- /dev/null +++ b/framework/src/modules/nft/events/lock.ts @@ -0,0 +1,61 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, + NftEventResult, +} from '../constants'; + +export interface LockEventData { + module: string; + nftID: Buffer; +} + +export const lockEventSchema = { + $id: '/nft/events/lock', + type: 'object', + required: ['module', 'nftID', 'result'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + result: { + dataType: 'uint32', + fieldNumber: 3, + }, + }, +}; + +export class LockEvent extends BaseEvent { + public schema = lockEventSchema; + + public log(ctx: EventQueuer, data: LockEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + Buffer.from(data.module), + data.nftID, + ]); + } +} diff --git a/framework/src/modules/nft/events/recover.ts b/framework/src/modules/nft/events/recover.ts new file mode 100644 index 00000000000..589e0585f12 --- /dev/null +++ b/framework/src/modules/nft/events/recover.ts @@ -0,0 +1,53 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult } from '../constants'; + +export interface RecoverEventData { + terminatedChainID: Buffer; + nftID: Buffer; +} + +export const recoverEventSchema = { + $id: '/nft/events/recover', + type: 'object', + required: ['terminatedChainID', 'nftID', 'result'], + properties: { + terminatedChainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + result: { + dataType: 'uint32', + fieldNumber: 3, + }, + }, +}; + +export class RecoverEvent extends BaseEvent { + public schema = recoverEventSchema; + + public log(ctx: EventQueuer, data: RecoverEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [data.nftID]); + } +} diff --git a/framework/src/modules/nft/events/set_attributes.ts b/framework/src/modules/nft/events/set_attributes.ts new file mode 100644 index 00000000000..7d6ef954240 --- /dev/null +++ b/framework/src/modules/nft/events/set_attributes.ts @@ -0,0 +1,53 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, NftEventResult } from '../constants'; + +export interface SetAttributesEventData { + nftID: Buffer; + attributes: Buffer; +} + +export const setAttributesEventSchema = { + $id: '/nft/events/setAttributes', + type: 'object', + required: ['nftID', 'attributes', 'result'], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + result: { + dataType: 'uint32', + fieldNumber: 3, + }, + }, +}; + +export class SetAttributesEvent extends BaseEvent< + SetAttributesEventData & { result: NftEventResult } +> { + public schema = setAttributesEventSchema; + + public log(ctx: EventQueuer, data: SetAttributesEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [data.nftID]); + } +} diff --git a/framework/src/modules/nft/events/transfer.ts b/framework/src/modules/nft/events/transfer.ts new file mode 100644 index 00000000000..591abc8c306 --- /dev/null +++ b/framework/src/modules/nft/events/transfer.ts @@ -0,0 +1,65 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, NftErrorEventResult, NftEventResult } from '../constants'; + +export interface TransferEventData { + senderAddress: Buffer; + recipientAddress: Buffer; + nftID: Buffer; +} + +export const transferEventSchema = { + $id: '/nft/events/transfer', + type: 'object', + required: ['senderAddress', 'recipientAddress', 'nftID', 'result'], + properties: { + senderAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 3, + }, + result: { + dataType: 'uint32', + fieldNumber: 4, + }, + }, +}; + +export class TransferEvent extends BaseEvent { + public schema = transferEventSchema; + + public log(ctx: EventQueuer, data: TransferEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.senderAddress, + data.recipientAddress, + ]); + } + + public error(ctx: EventQueuer, data: TransferEventData, result: NftErrorEventResult): void { + this.add(ctx, { ...data, result }, [data.senderAddress, data.recipientAddress], true); + } +} diff --git a/framework/src/modules/nft/events/transfer_cross_chain.ts b/framework/src/modules/nft/events/transfer_cross_chain.ts new file mode 100644 index 00000000000..bc864793143 --- /dev/null +++ b/framework/src/modules/nft/events/transfer_cross_chain.ts @@ -0,0 +1,76 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult } from '../constants'; + +export interface TransferCrossChainEventData { + senderAddress: Buffer; + recipientAddress: Buffer; + receivingChainID: Buffer; + nftID: Buffer; + includeAttributes: boolean; +} + +export const transferCrossChainEventSchema = { + $id: '/nft/events/transferCrossChain', + type: 'object', + required: ['senderAddress', 'recipientAddress', 'nftID', 'receivingChainID', 'result'], + properties: { + senderAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 3, + }, + receivingChainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 4, + }, + includeAttributes: { + dataType: 'boolean', + fieldNumber: 5, + }, + result: { + dataType: 'uint32', + fieldNumber: 6, + }, + }, +}; + +export class TransferCrossChainEvent extends BaseEvent< + TransferCrossChainEventData & { result: NftEventResult } +> { + public schema = transferCrossChainEventSchema; + + public log(ctx: EventQueuer, data: TransferCrossChainEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.senderAddress, + data.recipientAddress, + data.receivingChainID, + ]); + } +} diff --git a/framework/src/modules/nft/events/unlock.ts b/framework/src/modules/nft/events/unlock.ts new file mode 100644 index 00000000000..63629e7b5e9 --- /dev/null +++ b/framework/src/modules/nft/events/unlock.ts @@ -0,0 +1,61 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEvent, EventQueuer } from '../../base_event'; +import { + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, + NftEventResult, +} from '../constants'; + +export interface UnlockEventData { + module: string; + nftID: Buffer; +} + +export const unlockEventSchema = { + $id: '/nft/events/unlock', + type: 'object', + required: ['module', 'nftID', 'result'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + result: { + dataType: 'uint32', + fieldNumber: 3, + }, + }, +}; + +export class UnlockEvent extends BaseEvent { + public schema = unlockEventSchema; + + public log(ctx: EventQueuer, data: UnlockEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + Buffer.from(data.module), + data.nftID, + ]); + } +} diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 55e24c14fc8..24fc41e4073 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -18,6 +18,20 @@ import { BaseInteroperableModule } from '../interoperability'; import { InteroperabilityMethod } from '../token/types'; import { NFTInteroperableMethod } from './cc_method'; import { NFTEndpoint } from './endpoint'; +import { AllNFTsFromChainSupportedEvent } from './events/all_nfts_from_chain_suported'; +import { AllNFTsFromCollectionSupportRemovedEvent } from './events/all_nfts_from_collection_support_removed'; +import { AllNFTsFromCollectionSupportedEvent } from './events/all_nfts_from_collection_suppported'; +import { AllNFTsSupportRemovedEvent } from './events/all_nfts_support_removed'; +import { AllNFTsSupportedEvent } from './events/all_nfts_supported'; +import { CcmTransferEvent } from './events/ccm_transfer'; +import { CreateEvent } from './events/create'; +import { DestroyEvent } from './events/destroy'; +import { LockEvent } from './events/lock'; +import { RecoverEvent } from './events/recover'; +import { SetAttributesEvent } from './events/set_attributes'; +import { TransferEvent } from './events/transfer'; +import { TransferCrossChainEvent } from './events/transfer_cross_chain'; +import { UnlockEvent } from './events/unlock'; import { InternalMethod } from './internal_method'; import { NFTMethod } from './method'; import { FeeMethod } from './types'; @@ -36,6 +50,30 @@ export class NFTModule extends BaseInteroperableModule { // eslint-disable-next-line no-useless-constructor public constructor() { super(); + this.events.register(TransferEvent, new TransferEvent(this.name)); + this.events.register(TransferCrossChainEvent, new TransferCrossChainEvent(this.name)); + this.events.register(CcmTransferEvent, new CcmTransferEvent(this.name)); + this.events.register(CreateEvent, new CreateEvent(this.name)); + this.events.register(DestroyEvent, new DestroyEvent(this.name)); + this.events.register(DestroyEvent, new DestroyEvent(this.name)); + this.events.register(LockEvent, new LockEvent(this.name)); + this.events.register(UnlockEvent, new UnlockEvent(this.name)); + this.events.register(SetAttributesEvent, new SetAttributesEvent(this.name)); + this.events.register(RecoverEvent, new RecoverEvent(this.name)); + this.events.register(AllNFTsSupportedEvent, new AllNFTsSupportedEvent(this.name)); + this.events.register(AllNFTsSupportRemovedEvent, new AllNFTsSupportRemovedEvent(this.name)); + this.events.register( + AllNFTsFromChainSupportedEvent, + new AllNFTsFromChainSupportedEvent(this.name), + ); + this.events.register( + AllNFTsFromCollectionSupportedEvent, + new AllNFTsFromCollectionSupportedEvent(this.name), + ); + this.events.register( + AllNFTsFromCollectionSupportRemovedEvent, + new AllNFTsFromCollectionSupportRemovedEvent(this.name), + ); } public addDependencies(interoperabilityMethod: InteroperabilityMethod, _feeMethod: FeeMethod) { From e9e7f2e5321d9ead5c15d4091a65fa7d33b2600d Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Thu, 1 Jun 2023 16:46:57 +0200 Subject: [PATCH 05/58] Transfer NFT Command (#8503) * :bug: Registers NFT stores * :seedling: Adds createNFTEntry and transferInternal to NFT module * :seedling: Adds getNFTOwner and getLockingModule to NFTMethods * :seedling: Adds TransferCommand to NFT module * :seedling: Adds InternalMethod#ceateUserEntry * :bug: Updates InternalMethod#transferInternal to create user entry for recipientAddress * :recycle: Removes redundant Params type * :recycle: :memo: Adds NFTAttributes interface * :recycle: tests for TransferCommand * :recycle: tests for InternalMethod * :recycle: Method#getLockingModule * :recycle: tests for Method * :recycle: Updates dependencies for InternalMethod and Method --- framework/src/modules/nft/commands/.gitkeep | 0 .../src/modules/nft/commands/transfer.ts | 91 ++++++ framework/src/modules/nft/constants.ts | 1 + framework/src/modules/nft/internal_method.ts | 69 ++++- framework/src/modules/nft/method.ts | 43 ++- framework/src/modules/nft/module.ts | 13 +- framework/src/modules/nft/schemas.ts | 28 ++ framework/src/modules/nft/stores/nft.ts | 10 +- .../modules/nft/commands/transfer.spec.ts | 273 ++++++++++++++++++ .../unit/modules/nft/internal_method.spec.ts | 153 ++++++++++ .../test/unit/modules/nft/method.spec.ts | 104 +++++++ 11 files changed, 770 insertions(+), 15 deletions(-) delete mode 100644 framework/src/modules/nft/commands/.gitkeep create mode 100644 framework/src/modules/nft/commands/transfer.ts create mode 100644 framework/test/unit/modules/nft/commands/transfer.spec.ts create mode 100644 framework/test/unit/modules/nft/internal_method.spec.ts create mode 100644 framework/test/unit/modules/nft/method.spec.ts diff --git a/framework/src/modules/nft/commands/.gitkeep b/framework/src/modules/nft/commands/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/framework/src/modules/nft/commands/transfer.ts b/framework/src/modules/nft/commands/transfer.ts new file mode 100644 index 00000000000..85a984c00a2 --- /dev/null +++ b/framework/src/modules/nft/commands/transfer.ts @@ -0,0 +1,91 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { validator } from '@liskhq/lisk-validator'; +import { + CommandExecuteContext, + CommandVerifyContext, + VerificationResult, + VerifyStatus, +} from '../../../state_machine'; +import { BaseCommand } from '../../base_command'; +import { transferParamsSchema } from '../schemas'; +import { NFTStore } from '../stores/nft'; +import { NFTMethod } from '../method'; +import { LENGTH_CHAIN_ID, NFT_NOT_LOCKED } from '../constants'; +import { InternalMethod } from '../internal_method'; + +export interface Params { + nftID: Buffer; + recipientAddress: Buffer; + data: string; +} + +export class TransferCommand extends BaseCommand { + public schema = transferParamsSchema; + private _method!: NFTMethod; + private _internalMethod!: InternalMethod; + + public init(args: { method: NFTMethod; internalMethod: InternalMethod }) { + this._method = args.method; + this._internalMethod = args.internalMethod; + } + + public async verify(context: CommandVerifyContext): Promise { + const { params } = context; + + validator.validate(this.schema, params); + + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(context, params.nftID); + + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this._method.getNFTOwner(context.getMethodContext(), params.nftID); + + if (owner.length === LENGTH_CHAIN_ID) { + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(context.transaction.senderAddress)) { + throw new Error('Transfer not initiated by the NFT owner'); + } + + const lockingModule = await this._method.getLockingModule( + context.getMethodContext(), + params.nftID, + ); + + if (lockingModule !== NFT_NOT_LOCKED) { + throw new Error('Locked NFTs cannot be transferred'); + } + + return { + status: VerifyStatus.OK, + }; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._internalMethod.transferInternal( + context.getMethodContext(), + params.recipientAddress, + params.nftID, + ); + } +} diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index c0cbc168a98..323e8dfc67f 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -18,6 +18,7 @@ export const LENGTH_COLLECTION_ID = 4; export const MIN_LENGTH_MODULE_NAME = 1; export const MAX_LENGTH_MODULE_NAME = 32; export const LENGTH_ADDRESS = 20; +export const NFT_NOT_LOCKED = 'nft'; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index 28c3ed9e09c..16f21c62cb1 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -13,13 +13,80 @@ */ import { BaseMethod } from '../base_method'; -import { ModuleConfig } from './types'; +import { NFTStore, NFTAttributes } from './stores/nft'; +import { InteroperabilityMethod, ModuleConfig } from './types'; +import { MethodContext } from '../../state_machine'; +import { TransferEvent } from './events/transfer'; +import { UserStore } from './stores/user'; +import { NFT_NOT_LOCKED } from './constants'; +import { NFTMethod } from './method'; export class InternalMethod extends BaseMethod { // @ts-expect-error TODO: unused error. Remove when implementing. private _config!: ModuleConfig; + // @ts-expect-error TODO: unused error. Remove when implementing. + private _method!: NFTMethod; + + // @ts-expect-error TODO: unused error. Remove when implementing. + private _interoperabilityMethod!: InteroperabilityMethod; + public init(config: ModuleConfig): void { this._config = config; } + + public addDependencies(method: NFTMethod, interoperabilityMethod: InteroperabilityMethod) { + this._method = method; + this._interoperabilityMethod = interoperabilityMethod; + } + + public async createUserEntry( + methodContext: MethodContext, + address: Buffer, + nftID: Buffer, + ): Promise { + const userStore = this.stores.get(UserStore); + + await userStore.set(methodContext, userStore.getKey(address, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + } + + public async createNFTEntry( + methodContext: MethodContext, + address: Buffer, + nftID: Buffer, + attributesArray: NFTAttributes[], + ): Promise { + const nftStore = this.stores.get(NFTStore); + await nftStore.save(methodContext, nftID, { + owner: address, + attributesArray, + }); + } + + public async transferInternal( + methodContext: MethodContext, + recipientAddress: Buffer, + nftID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const userStore = this.stores.get(UserStore); + + const data = await nftStore.get(methodContext, nftID); + const senderAddress = data.owner; + + data.owner = recipientAddress; + + await nftStore.set(methodContext, nftID, data); + + await userStore.del(methodContext, userStore.getKey(senderAddress, nftID)); + await this.createUserEntry(methodContext, recipientAddress, nftID); + + this.events.get(TransferEvent).log(methodContext, { + senderAddress, + recipientAddress, + nftID, + }); + } } diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 16b611a5766..cccadfa8523 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -13,25 +13,52 @@ */ import { BaseMethod } from '../base_method'; import { InteroperabilityMethod, ModuleConfig } from './types'; -import { InternalMethod } from './internal_method'; +import { NFTStore } from './stores/nft'; +import { ImmutableMethodContext } from '../../state_machine'; +import { LENGTH_CHAIN_ID } from './constants'; +import { UserStore } from './stores/user'; export class NFTMethod extends BaseMethod { // @ts-expect-error TODO: unused error. Remove when implementing. private _config!: ModuleConfig; // @ts-expect-error TODO: unused error. Remove when implementing. private _interoperabilityMethod!: InteroperabilityMethod; - // @ts-expect-error TODO: unused error. Remove when implementing. - private _internalMethod!: InternalMethod; public init(config: ModuleConfig): void { this._config = config; } - public addDependencies( - interoperabilityMethod: InteroperabilityMethod, - internalMethod: InternalMethod, - ) { + public addDependencies(interoperabilityMethod: InteroperabilityMethod) { this._interoperabilityMethod = interoperabilityMethod; - this._internalMethod = internalMethod; + } + + public async getNFTOwner(methodContext: ImmutableMethodContext, nftID: Buffer): Promise { + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(methodContext, nftID); + + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const data = await nftStore.get(methodContext, nftID); + + return data.owner; + } + + public async getLockingModule( + methodContext: ImmutableMethodContext, + nftID: Buffer, + ): Promise { + const owner = await this.getNFTOwner(methodContext, nftID); + + if (owner.length === LENGTH_CHAIN_ID) { + throw new Error('NFT is escrowed to another chain'); + } + + const userStore = this.stores.get(UserStore); + const userData = await userStore.get(methodContext, userStore.getKey(owner, nftID)); + + return userData.lockingModule; } } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 24fc41e4073..62dd39ab424 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -34,6 +34,10 @@ import { TransferCrossChainEvent } from './events/transfer_cross_chain'; import { UnlockEvent } from './events/unlock'; import { InternalMethod } from './internal_method'; import { NFTMethod } from './method'; +import { EscrowStore } from './stores/escrow'; +import { NFTStore } from './stores/nft'; +import { SupportedNFTsStore } from './stores/supported_nfts'; +import { UserStore } from './stores/user'; import { FeeMethod } from './types'; export class NFTModule extends BaseInteroperableModule { @@ -42,7 +46,7 @@ export class NFTModule extends BaseInteroperableModule { public crossChainMethod = new NFTInteroperableMethod(this.stores, this.events); private readonly _internalMethod = new InternalMethod(this.stores, this.events); - // @ts-expect-error TODO: unused error. Remove when implementing. + private _interoperabilityMethod!: InteroperabilityMethod; public commands = []; @@ -74,11 +78,16 @@ export class NFTModule extends BaseInteroperableModule { AllNFTsFromCollectionSupportRemovedEvent, new AllNFTsFromCollectionSupportRemovedEvent(this.name), ); + this.stores.register(NFTStore, new NFTStore(this.name, 1)); + this.stores.register(UserStore, new UserStore(this.name, 2)); + this.stores.register(EscrowStore, new EscrowStore(this.name, 3)); + this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); } public addDependencies(interoperabilityMethod: InteroperabilityMethod, _feeMethod: FeeMethod) { this._interoperabilityMethod = interoperabilityMethod; - this.method.addDependencies(interoperabilityMethod, this._internalMethod); + this.method.addDependencies(interoperabilityMethod); + this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); this.crossChainMethod.addDependencies(interoperabilityMethod); } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 206ba71de27..2c0cee0da4e 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -11,3 +11,31 @@ * * Removal or modification of this copyright notice is prohibited. */ + +import { MAX_DATA_LENGTH } from '../token/constants'; +import { LENGTH_NFT_ID } from './constants'; + +export const transferParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['nftID', 'recipientAddress', 'data'], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + data: { + dataType: 'string', + minLength: 0, + maxLength: MAX_DATA_LENGTH, + fieldNumber: 3, + }, + }, +}; diff --git a/framework/src/modules/nft/stores/nft.ts b/framework/src/modules/nft/stores/nft.ts index c1e8ce23244..ec931e7be7b 100644 --- a/framework/src/modules/nft/stores/nft.ts +++ b/framework/src/modules/nft/stores/nft.ts @@ -15,12 +15,14 @@ import { BaseStore, StoreGetter } from '../../base_store'; import { MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from '../constants'; +export interface NFTAttributes { + module: string; + attributes: Buffer; +} + export interface NFTStoreData { owner: Buffer; - attributesArray: { - module: string; - attributes: Buffer; - }[]; + attributesArray: NFTAttributes[]; } export const nftStoreSchema = { diff --git a/framework/test/unit/modules/nft/commands/transfer.spec.ts b/framework/test/unit/modules/nft/commands/transfer.spec.ts new file mode 100644 index 00000000000..a4cae3f8054 --- /dev/null +++ b/framework/test/unit/modules/nft/commands/transfer.spec.ts @@ -0,0 +1,273 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { Transaction } from '@liskhq/lisk-chain'; +import { codec } from '@liskhq/lisk-codec'; +import { utils, address } from '@liskhq/lisk-cryptography'; +import { NFTModule } from '../../../../../src/modules/nft/module'; +import { TransferCommand, Params } from '../../../../../src/modules/nft/commands/transfer'; +import { createTransactionContext } from '../../../../../src/testing'; +import { transferParamsSchema } from '../../../../../src/modules/nft/schemas'; +import { + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_NFT_ID, + NFT_NOT_LOCKED, +} from '../../../../../src/modules/nft/constants'; +import { NFTAttributes, NFTStore } from '../../../../../src/modules/nft/stores/nft'; +import { createStoreGetter } from '../../../../../src/testing/utils'; +import { VerifyStatus } from '../../../../../src'; +import { InternalMethod } from '../../../../../src/modules/nft/internal_method'; +import { NFTMethod } from '../../../../../src/modules/nft/method'; +import { UserStore } from '../../../../../src/modules/nft/stores/user'; +import { EventQueue } from '../../../../../src/state_machine'; +import { TransferEvent } from '../../../../../src/modules/nft/events/transfer'; + +describe('Transfer command', () => { + const module = new NFTModule(); + const method = new NFTMethod(module.stores, module.events); + const internalMethod = new InternalMethod(module.stores, module.events); + let command: TransferCommand; + + const validParams: Params = { + nftID: Buffer.alloc(LENGTH_NFT_ID, 1), + recipientAddress: utils.getRandomBytes(20), + data: '', + }; + + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: any, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + + const createTransactionContextWithOverridingParams = ( + params: Record, + txParams: Record = {}, + ) => + createTransactionContext({ + transaction: new Transaction({ + module: module.name, + command: 'transfer', + fee: BigInt(5000000), + nonce: BigInt(0), + senderPublicKey: utils.getRandomBytes(32), + params: codec.encode(transferParamsSchema, { + ...validParams, + ...params, + }), + signatures: [utils.getRandomBytes(64)], + ...txParams, + }), + }); + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const senderPublicKey = utils.getRandomBytes(32); + const owner = address.getAddressFromPublicKey(senderPublicKey); + + beforeEach(() => { + command = new TransferCommand(module.stores, module.events); + command.init({ method, internalMethod }); + }); + + describe('verify', () => { + it('should fail if nftID does not have valid length', async () => { + const nftMinLengthContext = createTransactionContextWithOverridingParams({ + nftID: Buffer.alloc(LENGTH_NFT_ID - 1, 1), + }); + + const nftMaxLengthContext = createTransactionContextWithOverridingParams({ + nftID: Buffer.alloc(LENGTH_NFT_ID + 1, 1), + }); + + await expect( + command.verify(nftMinLengthContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow("'.nftID' minLength not satisfied"); + + await expect( + command.verify(nftMaxLengthContext.createCommandExecuteContext(transferParamsSchema)), + ).rejects.toThrow("'.nftID' maxLength exceeded"); + }); + + it('should fail if recipientAddress is not 20 bytes', async () => { + const recipientAddressIncorrectLengthContext = createTransactionContextWithOverridingParams({ + recipientAddress: utils.getRandomBytes(22), + }); + + await expect( + command.verify( + recipientAddressIncorrectLengthContext.createCommandVerifyContext(transferParamsSchema), + ), + ).rejects.toThrow("'.recipientAddress' address length invalid"); + }); + + it('should fail if data exceeds 64 characters', async () => { + const dataIncorrectLengthContext = createTransactionContextWithOverridingParams({ + data: '1'.repeat(65), + }); + + await expect( + command.verify(dataIncorrectLengthContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow("'.data' must NOT have more than 64 characters"); + }); + + it('should fail if nftID does not exist', async () => { + const nftIDNotExistingContext = createTransactionContextWithOverridingParams({ + nftID: Buffer.alloc(LENGTH_NFT_ID, 0), + }); + + await expect( + command.verify(nftIDNotExistingContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow('NFT substore entry does not exist'); + }); + + it('should fail if NFT is escrowed to another chain', async () => { + const nftEscrowedContext = createTransactionContextWithOverridingParams({ + nftID, + }); + + await nftStore.set(createStoreGetter(nftEscrowedContext.stateStore), nftID, { + owner: chainID, + attributesArray: [], + }); + + await expect( + command.verify(nftEscrowedContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow('NFT is escrowed to another chain'); + }); + + it('should fail if owner of the NFT is not the sender', async () => { + const nftIncorrectOwnerContext = createTransactionContextWithOverridingParams({ + nftID, + }); + + await nftStore.save(createStoreGetter(nftIncorrectOwnerContext.stateStore), nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await expect( + command.verify(nftIncorrectOwnerContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + }); + + it('should fail if NFT exists and is locked by its owner', async () => { + const lockedNFTContext = createTransactionContextWithOverridingParams( + { nftID }, + { senderPublicKey }, + ); + + await nftStore.save(createStoreGetter(lockedNFTContext.stateStore), nftID, { + owner, + attributesArray: [], + }); + + await userStore.set( + createStoreGetter(lockedNFTContext.stateStore), + userStore.getKey(owner, nftID), + { + lockingModule: 'token', + }, + ); + + await expect( + command.verify(lockedNFTContext.createCommandVerifyContext(transferParamsSchema)), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + }); + + it('should verify if unlocked NFT exists and its owner is performing the transfer', async () => { + const validContext = createTransactionContextWithOverridingParams( + { nftID }, + { senderPublicKey }, + ); + + await nftStore.save(createStoreGetter(validContext.stateStore), nftID, { + owner, + attributesArray: [], + }); + + await userStore.set( + createStoreGetter(validContext.stateStore), + userStore.getKey(owner, nftID), + { + lockingModule: NFT_NOT_LOCKED, + }, + ); + + await expect( + command.verify(validContext.createCommandVerifyContext(transferParamsSchema)), + ).resolves.toEqual({ status: VerifyStatus.OK }); + }); + }); + + describe('execute', () => { + it('should transfer NFT and emit Transfer event', async () => { + const senderAddress = owner; + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const attributesArray: NFTAttributes[] = []; + + const validContext = createTransactionContextWithOverridingParams( + { nftID, recipientAddress }, + { senderPublicKey }, + ); + + await nftStore.save(createStoreGetter(validContext.stateStore), nftID, { + owner: senderAddress, + attributesArray, + }); + + await userStore.set( + createStoreGetter(validContext.stateStore), + userStore.getKey(senderAddress, nftID), + { + lockingModule: NFT_NOT_LOCKED, + }, + ); + + await expect( + command.execute(validContext.createCommandExecuteContext(transferParamsSchema)), + ).resolves.toBeUndefined(); + + await expect( + nftStore.get(createStoreGetter(validContext.stateStore), nftID), + ).resolves.toEqual({ + owner: recipientAddress, + attributesArray, + }); + + checkEventResult(validContext.eventQueue, 1, TransferEvent, 0, { + senderAddress, + recipientAddress, + nftID, + }); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts new file mode 100644 index 00000000000..2e46a80553c --- /dev/null +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { codec } from '@liskhq/lisk-codec'; +import { NFTModule } from '../../../../src/modules/nft/module'; +import { InternalMethod } from '../../../../src/modules/nft/internal_method'; +import { EventQueue, createMethodContext } from '../../../../src/state_machine'; +import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; +import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; +import { + LENGTH_ADDRESS, + LENGTH_NFT_ID, + NFT_NOT_LOCKED, +} from '../../../../src/modules/nft/constants'; +import { NFTStore } from '../../../../src/modules/nft/stores/nft'; +import { MethodContext } from '../../../../src/state_machine/method_context'; +import { TransferEvent } from '../../../../src/modules/nft/events/transfer'; +import { UserStore } from '../../../../src/modules/nft/stores/user'; + +describe('InternalMethod', () => { + const module = new NFTModule(); + const internalMethod = new InternalMethod(module.stores, module.events); + let methodContext!: MethodContext; + + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: any, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + + const userStore = module.stores.get(UserStore); + const nftStore = module.stores.get(NFTStore); + + const address = utils.getRandomBytes(LENGTH_ADDRESS); + const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + beforeEach(() => { + methodContext = createMethodContext({ + stateStore: new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()), + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + }); + + describe('createNFTEntry', () => { + it('should create an entry in NFStore with attributes sorted by module', async () => { + const unsortedAttributesArray = [ + { + module: 'token', + attributes: Buffer.alloc(8, 1), + }, + { + module: 'pos', + attributes: Buffer.alloc(8, 1), + }, + ]; + + const sortedAttributesArray = unsortedAttributesArray.sort((a, b) => + a.module.localeCompare(b.module, 'en'), + ); + + await internalMethod.createNFTEntry(methodContext, address, nftID, unsortedAttributesArray); + + await expect(nftStore.get(methodContext, nftID)).resolves.toEqual({ + owner: address, + attributesArray: sortedAttributesArray, + }); + }); + }); + + describe('createUserEntry', () => { + it('should create an entry for an unlocked NFT in UserStore', async () => { + await expect( + internalMethod.createUserEntry(methodContext, address, nftID), + ).resolves.toBeUndefined(); + + await expect(userStore.get(methodContext, userStore.getKey(address, nftID))).resolves.toEqual( + { + lockingModule: NFT_NOT_LOCKED, + }, + ); + }); + }); + + describe('transferInternal', () => { + it('should transfer NFT from sender to recipient and emit Transfer event', async () => { + await module.stores.get(NFTStore).save(methodContext, nftID, { + owner: senderAddress, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await internalMethod.transferInternal(methodContext, recipientAddress, nftID); + + await expect(module.stores.get(NFTStore).get(methodContext, nftID)).resolves.toEqual({ + owner: recipientAddress, + attributesArray: [], + }); + + await expect( + userStore.has(methodContext, userStore.getKey(senderAddress, nftID)), + ).resolves.toBeFalse(); + + await expect( + userStore.get(methodContext, userStore.getKey(recipientAddress, nftID)), + ).resolves.toEqual({ + lockingModule: NFT_NOT_LOCKED, + }); + + checkEventResult(methodContext.eventQueue, 1, TransferEvent, 0, { + senderAddress, + recipientAddress, + nftID, + }); + }); + + it('should fail if NFT does not exist', async () => { + await expect( + internalMethod.transferInternal(methodContext, recipientAddress, nftID), + ).rejects.toThrow('does not exist'); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts new file mode 100644 index 00000000000..346f7473a59 --- /dev/null +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -0,0 +1,104 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { NFTMethod } from '../../../../src/modules/nft/method'; +import { NFTModule } from '../../../../src/modules/nft/module'; +import { EventQueue } from '../../../../src/state_machine'; +import { MethodContext, createMethodContext } from '../../../../src/state_machine/method_context'; +import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; +import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; +import { + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_NFT_ID, +} from '../../../../src/modules/nft/constants'; +import { NFTStore } from '../../../../src/modules/nft/stores/nft'; +import { UserStore } from '../../../../src/modules/nft/stores/user'; + +describe('NFTMethods', () => { + const module = new NFTModule(); + const method = new NFTMethod(module.stores, module.events); + + let methodContext!: MethodContext; + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + let owner: Buffer; + + beforeEach(() => { + owner = utils.getRandomBytes(LENGTH_ADDRESS); + + methodContext = createMethodContext({ + stateStore: new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()), + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + }); + + describe('getNFTOwner', () => { + it('should fail if NFT does not exist', async () => { + await expect(method.getNFTOwner(methodContext, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should return the owner if NFT exists', async () => { + await nftStore.save(methodContext, nftID, { + owner, + attributesArray: [], + }); + + await expect(method.getNFTOwner(methodContext, nftID)).resolves.toEqual(owner); + }); + }); + + describe('getLockingModule', () => { + it('should fail if NFT does not exist', async () => { + await expect(method.getLockingModule(methodContext, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should fail if NFT is escrowed', async () => { + owner = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await nftStore.save(methodContext, nftID, { + owner, + attributesArray: [], + }); + + await expect(method.getLockingModule(methodContext, nftID)).rejects.toThrow( + 'NFT is escrowed to another chain', + ); + }); + + it('should return the lockingModule for the owner of the NFT', async () => { + const lockingModule = 'nft'; + + await nftStore.save(methodContext, nftID, { + owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(owner, nftID), { + lockingModule, + }); + + await expect(method.getLockingModule(methodContext, nftID)).resolves.toEqual(lockingModule); + }); + }); +}); From 99b0ed4c1663c98c92dca6ebcc444a77805290d2 Mon Sep 17 00:00:00 2001 From: has5aan Date: Wed, 31 May 2023 14:12:34 +0200 Subject: [PATCH 06/58] :seedling: Adds EscrowStore#getKey --- framework/src/modules/nft/stores/escrow.ts | 4 +++ .../unit/modules/nft/stores/escrow.spec.ts | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 framework/test/unit/modules/nft/stores/escrow.spec.ts diff --git a/framework/src/modules/nft/stores/escrow.ts b/framework/src/modules/nft/stores/escrow.ts index 719bf0b7fbe..b5d224088bd 100644 --- a/framework/src/modules/nft/stores/escrow.ts +++ b/framework/src/modules/nft/stores/escrow.ts @@ -25,4 +25,8 @@ type EscrowStoreData = Record; export class EscrowStore extends BaseStore { public schema = escrowStoreSchema; + + public getKey(receivingChainID: Buffer, nftID: Buffer): Buffer { + return Buffer.concat([receivingChainID, nftID]); + } } diff --git a/framework/test/unit/modules/nft/stores/escrow.spec.ts b/framework/test/unit/modules/nft/stores/escrow.spec.ts new file mode 100644 index 00000000000..89d27e973af --- /dev/null +++ b/framework/test/unit/modules/nft/stores/escrow.spec.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { EscrowStore } from '../../../../../src/modules/nft/stores/escrow'; +import { LENGTH_CHAIN_ID, LENGTH_NFT_ID } from '../../../../../src/modules/nft/constants'; + +describe('EscrowStore', () => { + let store: EscrowStore; + + beforeEach(() => { + store = new EscrowStore('NFT', 5); + }); + + describe('getKey', () => { + it('should concatenate the provided receivingChainID and nftID', () => { + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + expect(store.getKey(receivingChainID, nftID)).toEqual( + Buffer.concat([receivingChainID, nftID]), + ); + }); + }); +}); From 332c8fa942fd1d09ff8712aea71d23999de80259 Mon Sep 17 00:00:00 2001 From: has5aan Date: Wed, 31 May 2023 14:14:46 +0200 Subject: [PATCH 07/58] :bug: Fixes schema for DestroyEvent --- framework/src/modules/nft/events/destroy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/src/modules/nft/events/destroy.ts b/framework/src/modules/nft/events/destroy.ts index 1294f466ba9..c4c6e0e1923 100644 --- a/framework/src/modules/nft/events/destroy.ts +++ b/framework/src/modules/nft/events/destroy.ts @@ -33,7 +33,7 @@ export const createEventSchema = { nftID: { dataType: 'bytes', minLength: LENGTH_NFT_ID, - maxLenght: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, fieldNumber: 2, }, result: { From 138fbecb62f85d7d9385781021eeb8a1714e6d68 Mon Sep 17 00:00:00 2001 From: has5aan Date: Wed, 31 May 2023 14:19:58 +0200 Subject: [PATCH 08/58] :recycle: Adds result parameter to DestroyEvent#log --- framework/src/modules/nft/events/destroy.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/framework/src/modules/nft/events/destroy.ts b/framework/src/modules/nft/events/destroy.ts index c4c6e0e1923..3475c03a869 100644 --- a/framework/src/modules/nft/events/destroy.ts +++ b/framework/src/modules/nft/events/destroy.ts @@ -46,10 +46,11 @@ export const createEventSchema = { export class DestroyEvent extends BaseEvent { public schema = createEventSchema; - public log(ctx: EventQueuer, data: DestroyEventData): void { - this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ - data.address, - data.nftID, - ]); + public log( + ctx: EventQueuer, + data: DestroyEventData, + result: NftEventResult = NftEventResult.RESULT_SUCCESSFUL, + ): void { + this.add(ctx, { ...data, result }, [data.address, data.nftID]); } } From 029710afd9aa770ada31fb2dc7e36722868d12d4 Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 1 Jun 2023 14:03:09 +0200 Subject: [PATCH 09/58] :seedling: Adds NFTMethod.getChainID --- framework/src/modules/nft/method.ts | 10 +++++++++- framework/test/unit/modules/nft/method.spec.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index cccadfa8523..61129d21395 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -15,7 +15,7 @@ import { BaseMethod } from '../base_method'; import { InteroperabilityMethod, ModuleConfig } from './types'; import { NFTStore } from './stores/nft'; import { ImmutableMethodContext } from '../../state_machine'; -import { LENGTH_CHAIN_ID } from './constants'; +import { LENGTH_CHAIN_ID, LENGTH_NFT_ID } from './constants'; import { UserStore } from './stores/user'; export class NFTMethod extends BaseMethod { @@ -32,6 +32,14 @@ export class NFTMethod extends BaseMethod { this._interoperabilityMethod = interoperabilityMethod; } + public getChainID(nftID: Buffer): Buffer { + if (nftID.length !== LENGTH_NFT_ID) { + throw new Error(`NFT ID must have length ${LENGTH_NFT_ID}`); + } + + return nftID.slice(0, LENGTH_CHAIN_ID); + } + public async getNFTOwner(methodContext: ImmutableMethodContext, nftID: Buffer): Promise { const nftStore = this.stores.get(NFTStore); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 346f7473a59..f096aaa8799 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -49,6 +49,18 @@ describe('NFTMethods', () => { }); }); + describe('getChainID', () => { + it('should throw if nftID has invalid length', () => { + expect(() => { + method.getChainID(utils.getRandomBytes(LENGTH_NFT_ID - 1)); + }).toThrow(`NFT ID must have length ${LENGTH_NFT_ID}`); + }); + + it('should return the first bytes of length LENGTH_CHAIN_ID from provided nftID', () => { + expect(method.getChainID(nftID)).toEqual(nftID.slice(0, LENGTH_CHAIN_ID)); + }); + }); + describe('getNFTOwner', () => { it('should fail if NFT does not exist', async () => { await expect(method.getNFTOwner(methodContext, nftID)).rejects.toThrow( From 5936b7b2e6077a82e7d3ba0286dc18cef769c6e0 Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 1 Jun 2023 14:16:18 +0200 Subject: [PATCH 10/58] :seedling: Adds NFTMethod.destroy --- framework/src/modules/nft/method.ts | 82 +++++++- .../test/unit/modules/nft/method.spec.ts | 175 +++++++++++++++++- 2 files changed, 254 insertions(+), 3 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 61129d21395..a2f449879de 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -14,9 +14,10 @@ import { BaseMethod } from '../base_method'; import { InteroperabilityMethod, ModuleConfig } from './types'; import { NFTStore } from './stores/nft'; -import { ImmutableMethodContext } from '../../state_machine'; -import { LENGTH_CHAIN_ID, LENGTH_NFT_ID } from './constants'; +import { ImmutableMethodContext, MethodContext } from '../../state_machine'; +import { LENGTH_CHAIN_ID, LENGTH_NFT_ID, NFT_NOT_LOCKED, NftEventResult } from './constants'; import { UserStore } from './stores/user'; +import { DestroyEvent } from './events/destroy'; export class NFTMethod extends BaseMethod { // @ts-expect-error TODO: unused error. Remove when implementing. @@ -69,4 +70,81 @@ export class NFTMethod extends BaseMethod { return userData.lockingModule; } + + public async destroy( + methodContext: MethodContext, + address: Buffer, + nftID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(methodContext, nftID); + + if (!nftExists) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + + if (!owner.equals(address)) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + + throw new Error('Not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userKey = userStore.getKey(owner, nftID); + const { lockingModule } = await userStore.get(methodContext, userKey); + + if (lockingModule !== NFT_NOT_LOCKED) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + + throw new Error('Locked NFTs cannot be destroyed'); + } + + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + + throw new Error('NFT is escrowed to another chain'); + } + + await nftStore.del(methodContext, nftID); + + await userStore.del(methodContext, userKey); + + this.events.get(DestroyEvent).log(methodContext, { + address, + nftID, + }); + } } diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index f096aaa8799..8f227a8cb0a 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -12,6 +12,7 @@ * Removal or modification of this copyright notice is prohibited. */ +import { codec } from '@liskhq/lisk-codec'; import { utils } from '@liskhq/lisk-cryptography'; import { NFTMethod } from '../../../../src/modules/nft/method'; import { NFTModule } from '../../../../src/modules/nft/module'; @@ -23,11 +24,14 @@ import { LENGTH_ADDRESS, LENGTH_CHAIN_ID, LENGTH_NFT_ID, + NFT_NOT_LOCKED, + NftEventResult, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; -describe('NFTMethods', () => { +describe('NFTMethod', () => { const module = new NFTModule(); const method = new NFTMethod(module.stores, module.events); @@ -39,6 +43,25 @@ describe('NFTMethods', () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); let owner: Buffer; + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: EventDataType, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + beforeEach(() => { owner = utils.getRandomBytes(LENGTH_ADDRESS); @@ -113,4 +136,154 @@ describe('NFTMethods', () => { await expect(method.getLockingModule(methodContext, nftID)).resolves.toEqual(lockingModule); }); }); + + describe('destroy', () => { + let existingNFT: { nftID: any; owner: any }; + let lockedExistingNFT: { nftID: any; owner: any }; + let escrowedNFT: { nftID: any; owner: any }; + + beforeEach(async () => { + existingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + lockedExistingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + escrowedNFT = { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + await module.stores.get(NFTStore).save(methodContext, existingNFT.nftID, { + owner: existingNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await module.stores.get(NFTStore).save(methodContext, lockedExistingNFT.nftID, { + owner: lockedExistingNFT.owner, + attributesArray: [], + }); + + await userStore.set( + methodContext, + userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), + { + lockingModule: 'token', + }, + ); + + await module.stores.get(NFTStore).save(methodContext, escrowedNFT.nftID, { + owner: escrowedNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(escrowedNFT.owner, escrowedNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + }); + + it('should fail and emit Destroy event if NFT does not exist', async () => { + const address = utils.getRandomBytes(LENGTH_ADDRESS); + + await expect(method.destroy(methodContext, address, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should fail and emit Destroy event if NFT is not owned by the provided address', async () => { + const notOwner = utils.getRandomBytes(LENGTH_ADDRESS); + + await expect(method.destroy(methodContext, notOwner, existingNFT.nftID)).rejects.toThrow( + 'Not initiated by the NFT owner', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: notOwner, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should fail and emit Destroy event if NFT is locked', async () => { + await expect( + method.destroy(methodContext, lockedExistingNFT.owner, lockedExistingNFT.nftID), + ).rejects.toThrow('Locked NFTs cannot be destroyed'); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: lockedExistingNFT.owner, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should fail and emit Destroy event if NFT is escrowed', async () => { + await expect( + method.destroy(methodContext, escrowedNFT.owner, escrowedNFT.nftID), + ).rejects.toThrow(); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: escrowedNFT.owner, + nftID: escrowedNFT.nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should delete NFTStore and UserStore entry and emit Destroy event', async () => { + await expect( + method.destroy(methodContext, existingNFT.owner, existingNFT.nftID), + ).resolves.toBeUndefined(); + + await expect( + module.stores.get(NFTStore).has(methodContext, existingNFT.nftID), + ).resolves.toBeFalse(); + await expect( + module.stores + .get(UserStore) + .has(methodContext, Buffer.concat([existingNFT.owner, escrowedNFT.nftID])), + ).resolves.toBeFalse(); + + checkEventResult(methodContext.eventQueue, 1, DestroyEvent, 0, { + address: existingNFT.owner, + nftID: existingNFT.nftID, + }); + }); + }); }); From 55112a883ab089af79bba9e4713dcd8f8005d542 Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 1 Jun 2023 14:17:31 +0200 Subject: [PATCH 11/58] :seedling: Adds InternalMethod.createEscrowEntry --- framework/src/modules/nft/internal_method.ts | 11 +++++++++++ .../test/unit/modules/nft/internal_method.spec.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index 16f21c62cb1..bad4183c079 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -20,6 +20,7 @@ import { TransferEvent } from './events/transfer'; import { UserStore } from './stores/user'; import { NFT_NOT_LOCKED } from './constants'; import { NFTMethod } from './method'; +import { EscrowStore } from './stores/escrow'; export class InternalMethod extends BaseMethod { // @ts-expect-error TODO: unused error. Remove when implementing. @@ -40,6 +41,16 @@ export class InternalMethod extends BaseMethod { this._interoperabilityMethod = interoperabilityMethod; } + public async createEscrowEntry( + methodContext: MethodContext, + receivingChainID: Buffer, + nftID: Buffer, + ): Promise { + const escrowStore = this.stores.get(EscrowStore); + + await escrowStore.set(methodContext, escrowStore.getKey(receivingChainID, nftID), {}); + } + public async createUserEntry( methodContext: MethodContext, address: Buffer, diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 2e46a80553c..8ce8bdb255b 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -21,6 +21,7 @@ import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_ import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; import { LENGTH_ADDRESS, + LENGTH_CHAIN_ID, LENGTH_NFT_ID, NFT_NOT_LOCKED, } from '../../../../src/modules/nft/constants'; @@ -28,6 +29,7 @@ import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { MethodContext } from '../../../../src/state_machine/method_context'; import { TransferEvent } from '../../../../src/modules/nft/events/transfer'; import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; describe('InternalMethod', () => { const module = new NFTModule(); @@ -55,6 +57,7 @@ describe('InternalMethod', () => { const userStore = module.stores.get(UserStore); const nftStore = module.stores.get(NFTStore); + const escrowStore = module.stores.get(EscrowStore); const address = utils.getRandomBytes(LENGTH_ADDRESS); const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); @@ -69,6 +72,18 @@ describe('InternalMethod', () => { }); }); + describe('createEscrowEntry', () => { + it('should create an entry in EscrowStore', async () => { + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await internalMethod.createEscrowEntry(methodContext, receivingChainID, nftID); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + }); + }); + describe('createNFTEntry', () => { it('should create an entry in NFStore with attributes sorted by module', async () => { const unsortedAttributesArray = [ From 85594ff017294faa2dd2063b00173942226fbd67 Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 1 Jun 2023 14:20:12 +0200 Subject: [PATCH 12/58] :recycle: test for InternalMethod --- framework/test/unit/modules/nft/internal_method.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 8ce8bdb255b..3d7d42fc182 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -27,7 +27,7 @@ import { } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { MethodContext } from '../../../../src/state_machine/method_context'; -import { TransferEvent } from '../../../../src/modules/nft/events/transfer'; +import { TransferEvent, TransferEventData } from '../../../../src/modules/nft/events/transfer'; import { UserStore } from '../../../../src/modules/nft/stores/user'; import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; @@ -36,12 +36,12 @@ describe('InternalMethod', () => { const internalMethod = new InternalMethod(module.stores, module.events); let methodContext!: MethodContext; - const checkEventResult = ( + const checkEventResult = ( eventQueue: EventQueue, length: number, EventClass: any, index: number, - expectedResult: any, + expectedResult: EventDataType, result: any = 0, ) => { expect(eventQueue.getEvents()).toHaveLength(length); @@ -152,7 +152,7 @@ describe('InternalMethod', () => { lockingModule: NFT_NOT_LOCKED, }); - checkEventResult(methodContext.eventQueue, 1, TransferEvent, 0, { + checkEventResult(methodContext.eventQueue, 1, TransferEvent, 0, { senderAddress, recipientAddress, nftID, From e78c4756305d70f09198bbd65bc8b57e621a4c3f Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 1 Jun 2023 14:47:13 +0200 Subject: [PATCH 13/58] :seedling: Adds InternalMethod.transferCrossChainInternal --- framework/src/modules/nft/constants.ts | 4 +- framework/src/modules/nft/internal_method.ts | 80 +++- framework/src/modules/nft/schemas.ts | 52 ++- framework/src/modules/nft/types.ts | 4 +- .../unit/modules/nft/internal_method.spec.ts | 347 +++++++++++++++++- 5 files changed, 477 insertions(+), 10 deletions(-) diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index 323e8dfc67f..a63d4095872 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -18,7 +18,9 @@ export const LENGTH_COLLECTION_ID = 4; export const MIN_LENGTH_MODULE_NAME = 1; export const MAX_LENGTH_MODULE_NAME = 32; export const LENGTH_ADDRESS = 20; -export const NFT_NOT_LOCKED = 'nft'; +export const MODULE_NAME_NFT = 'nft'; +export const NFT_NOT_LOCKED = MODULE_NAME_NFT; +export const CROSS_CHAIN_COMMAND_NAME_TRANSFER = 'crossChainTransfer'; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index bad4183c079..da065eb78e8 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -12,24 +12,23 @@ * Removal or modification of this copyright notice is prohibited. */ +import { codec } from '@liskhq/lisk-codec'; import { BaseMethod } from '../base_method'; import { NFTStore, NFTAttributes } from './stores/nft'; import { InteroperabilityMethod, ModuleConfig } from './types'; import { MethodContext } from '../../state_machine'; import { TransferEvent } from './events/transfer'; import { UserStore } from './stores/user'; -import { NFT_NOT_LOCKED } from './constants'; +import { CROSS_CHAIN_COMMAND_NAME_TRANSFER, MODULE_NAME_NFT, NFT_NOT_LOCKED } from './constants'; import { NFTMethod } from './method'; import { EscrowStore } from './stores/escrow'; +import { TransferCrossChainEvent } from './events/transfer_cross_chain'; +import { CCM_STATUS_OK } from '../token/constants'; +import { crossChainNFTTransferMessageParamsSchema } from './schemas'; export class InternalMethod extends BaseMethod { - // @ts-expect-error TODO: unused error. Remove when implementing. private _config!: ModuleConfig; - - // @ts-expect-error TODO: unused error. Remove when implementing. private _method!: NFTMethod; - - // @ts-expect-error TODO: unused error. Remove when implementing. private _interoperabilityMethod!: InteroperabilityMethod; public init(config: ModuleConfig): void { @@ -100,4 +99,73 @@ export class InternalMethod extends BaseMethod { nftID, }); } + + public async transferCrossChainInternal( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + receivingChainID: Buffer, + messageFee: bigint, + data: string, + includeAttributes: boolean, + ): Promise { + const chainID = this._method.getChainID(nftID); + const nftStore = this.stores.get(NFTStore); + const nft = await nftStore.get(methodContext, nftID); + + if (chainID.equals(this._config.ownChainID)) { + const escrowStore = this.stores.get(EscrowStore); + const userStore = this.stores.get(UserStore); + + nft.owner = receivingChainID; + await nftStore.save(methodContext, nftID, nft); + + await userStore.del(methodContext, userStore.getKey(senderAddress, nftID)); + + const escrowExists = await escrowStore.has( + methodContext, + escrowStore.getKey(receivingChainID, nftID), + ); + + if (!escrowExists) { + await this.createEscrowEntry(methodContext, receivingChainID, nftID); + } + } + + if (chainID.equals(receivingChainID)) { + await this._method.destroy(methodContext, senderAddress, nftID); + } + + let attributes: { module: string; attributes: Buffer }[] = []; + + if (includeAttributes) { + attributes = nft.attributesArray; + } + + this.events.get(TransferCrossChainEvent).log(methodContext, { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }); + + await this._interoperabilityMethod.send( + methodContext, + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributes, + data, + }), + ); + } } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 2c0cee0da4e..a712f07e1e0 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -13,7 +13,7 @@ */ import { MAX_DATA_LENGTH } from '../token/constants'; -import { LENGTH_NFT_ID } from './constants'; +import { LENGTH_NFT_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from './constants'; export const transferParamsSchema = { $id: '/lisk/nftTransferParams', @@ -39,3 +39,53 @@ export const transferParamsSchema = { }, }, }; + +export const crossChainNFTTransferMessageParamsSchema = { + $id: '/lisk/crossChainNFTTransferMessageParamsSchmema', + type: 'object', + required: ['nftID', 'senderAddress', 'recipientAddress', 'attributes', 'data'], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + senderAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 3, + }, + attributes: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + data: { + dataType: 'string', + maxLength: MAX_DATA_LENGTH, + fieldNumber: 5, + }, + }, +}; diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 40fa051c2f8..74d123c56aa 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -16,7 +16,9 @@ import { MethodContext } from '../../state_machine'; import { CCMsg } from '../interoperability'; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ModuleConfig {} +export interface ModuleConfig { + ownChainID: Buffer; +} export interface InteroperabilityMethod { send( diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 3d7d42fc182..6b2d6a79f6a 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -20,9 +20,11 @@ import { EventQueue, createMethodContext } from '../../../../src/state_machine'; import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; import { + CROSS_CHAIN_COMMAND_NAME_TRANSFER, LENGTH_ADDRESS, LENGTH_CHAIN_ID, LENGTH_NFT_ID, + MODULE_NAME_NFT, NFT_NOT_LOCKED, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; @@ -30,10 +32,26 @@ import { MethodContext } from '../../../../src/state_machine/method_context'; import { TransferEvent, TransferEventData } from '../../../../src/modules/nft/events/transfer'; import { UserStore } from '../../../../src/modules/nft/stores/user'; import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; +import { NFTMethod } from '../../../../src/modules/nft/method'; +import { InteroperabilityMethod } from '../../../../src/modules/nft/types'; +import { + TransferCrossChainEvent, + TransferCrossChainEventData, +} from '../../../../src/modules/nft/events/transfer_cross_chain'; +import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; +import { CCM_STATUS_OK } from '../../../../src/modules/token/constants'; +import { crossChainNFTTransferMessageParamsSchema } from '../../../../src/modules/nft/schemas'; describe('InternalMethod', () => { const module = new NFTModule(); const internalMethod = new InternalMethod(module.stores, module.events); + const method = new NFTMethod(module.stores, module.events); + let interoperabilityMethod!: InteroperabilityMethod; + internalMethod.addDependencies(method, interoperabilityMethod); + + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + internalMethod.init({ ownChainID }); + let methodContext!: MethodContext; const checkEventResult = ( @@ -62,7 +80,7 @@ describe('InternalMethod', () => { const address = utils.getRandomBytes(LENGTH_ADDRESS); const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + let nftID = utils.getRandomBytes(LENGTH_NFT_ID); beforeEach(() => { methodContext = createMethodContext({ @@ -165,4 +183,331 @@ describe('InternalMethod', () => { ).rejects.toThrow('does not exist'); }); }); + + describe('transferCrossChainInternal', () => { + let receivingChainID: Buffer; + const messageFee = BigInt(1000); + const data = ''; + + beforeEach(() => { + receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + interoperabilityMethod = { + send: jest.fn().mockResolvedValue(Promise.resolve()), + error: jest.fn().mockResolvedValue(Promise.resolve()), + terminateChain: jest.fn().mockRejectedValue(Promise.resolve()), + }; + + internalMethod.addDependencies(method, interoperabilityMethod); + }); + + describe('if attributes are not included ccm contains empty attributes', () => { + const includeAttributes = false; + + it('should transfer the ownership of the NFT to the receiving chain and escrow it for a native NFT', async () => { + const chainID = ownChainID; + nftID = Buffer.concat([chainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributes: [], + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + await expect(nftStore.get(methodContext, nftID)).resolves.toEqual({ + owner: receivingChainID, + attributesArray: [], + }); + + await expect( + userStore.has(methodContext, userStore.getKey(senderAddress, nftID)), + ).resolves.toBeFalse(); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + + it('should destroy NFT if the chain ID of the NFT is the same as receiving chain', async () => { + nftID = Buffer.concat([ + receivingChainID, + utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID), + ]); + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributes: [], + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + checkEventResult(methodContext.eventQueue, 2, DestroyEvent, 0, { + address: senderAddress, + nftID, + }); + + checkEventResult( + methodContext.eventQueue, + 2, + TransferCrossChainEvent, + 1, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + }); + + describe('if attributes are included ccm contains attributes of the NFT', () => { + const includeAttributes = true; + + it('should transfer the ownership of the NFT to the receiving chain and escrow it for a native NFT', async () => { + const chainID = ownChainID; + nftID = Buffer.concat([chainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + const attributesArray = [ + { + module: 'pos', + attributes: utils.getRandomBytes(20), + }, + ]; + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributes: attributesArray, + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + await expect(nftStore.get(methodContext, nftID)).resolves.toEqual({ + owner: receivingChainID, + attributesArray, + }); + + await expect( + userStore.has(methodContext, userStore.getKey(senderAddress, nftID)), + ).resolves.toBeFalse(); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + + it('should destroy NFT if the chain ID of the NFT is the same as receiving chain', async () => { + nftID = Buffer.concat([ + receivingChainID, + utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID), + ]); + + const attributesArray = [ + { + module: 'pos', + attributes: utils.getRandomBytes(20), + }, + ]; + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributes: attributesArray, + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + checkEventResult(methodContext.eventQueue, 2, DestroyEvent, 0, { + address: senderAddress, + nftID, + }); + + checkEventResult( + methodContext.eventQueue, + 2, + TransferCrossChainEvent, + 1, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + }); + }); }); From 7c668f10a77f4054630ccf600fa8ea4664defbcd Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Sun, 4 Jun 2023 20:44:39 +0200 Subject: [PATCH 14/58] Implement cross chain command --- .../modules/nft/cc_commands/cc_transfer.ts | 154 +++++ framework/src/modules/nft/constants.ts | 4 + .../src/modules/nft/events/ccm_transfer.ts | 4 + framework/src/modules/nft/internal_method.ts | 4 + framework/src/modules/nft/method.ts | 60 +- framework/src/modules/nft/schemas.ts | 12 +- .../modules/token/cc_commands/cc_transfer.ts | 1 - .../nft/cc_comands/cc_transfer.spec.ts | 615 ++++++++++++++++++ .../test/unit/modules/nft/method.spec.ts | 124 ++++ 9 files changed, 973 insertions(+), 5 deletions(-) create mode 100644 framework/src/modules/nft/cc_commands/cc_transfer.ts create mode 100644 framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts new file mode 100644 index 00000000000..e172160896e --- /dev/null +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -0,0 +1,154 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { codec } from '@liskhq/lisk-codec'; +import { validator } from '@liskhq/lisk-validator'; +import { CCTransferMessageParams, crossChainNFTTransferMessageParamsSchema } from '../schemas'; +import { NFTAttributes, NFTStore } from '../stores/nft'; +import { NFTMethod } from '../method'; +import { + CCM_STATUS_CODE_OK, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + FEE_CREATE_NFT, + NftEventResult, +} from '../constants'; +import { InternalMethod } from '../internal_method'; +import { BaseCCCommand } from '../../interoperability/base_cc_command'; +import { CrossChainMessageContext } from '../../interoperability/types'; +import { MAX_RESERVED_ERROR_STATUS } from '../../interoperability/constants'; +import { FeeMethod } from '../types'; +import { EscrowStore } from '../stores/escrow'; +import { CcmTransferEvent } from '../events/ccm_transfer'; + +export class CrossChainTransferCommand extends BaseCCCommand { + public schema = crossChainNFTTransferMessageParamsSchema; + private _method!: NFTMethod; + private _internalMethod!: InternalMethod; + private _feeMethod!: FeeMethod; + + public get name(): string { + return CROSS_CHAIN_COMMAND_NAME_TRANSFER; + } + + public init(args: { method: NFTMethod; internalMethod: InternalMethod; feeMethod: FeeMethod }) { + this._method = args.method; + this._internalMethod = args.internalMethod; + this._feeMethod = args.feeMethod; + } + + public async verify(context: CrossChainMessageContext): Promise { + const { ccm, getMethodContext } = context; + const params = codec.decode( + crossChainNFTTransferMessageParamsSchema, + ccm.params, + ); + validator.validate(crossChainNFTTransferMessageParamsSchema, params); + + if (ccm.status > MAX_RESERVED_ERROR_STATUS) { + throw new Error('Invalid CCM error code'); + } + + const { nftID } = params; + const { sendingChainID } = ccm; + const nftChainID = this._method.getChainID(nftID); + const ownChainID = this._internalMethod.getOwnChainID(); + + if (![ownChainID, sendingChainID].some(allowedChainID => nftChainID.equals(allowedChainID))) { + throw new Error('NFT is not native to either the sending chain or the receiving chain'); + } + + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(getMethodContext(), nftID); + if (nftChainID.equals(ownChainID) && !nftExists) { + throw new Error('Non-existent entry in the NFT substore'); + } + + const owner = await this._method.getNFTOwner(getMethodContext(), nftID); + if (nftChainID.equals(ownChainID) && !owner.equals(sendingChainID)) { + throw new Error('NFT has not been properly escrowed'); + } + + if (!nftChainID.equals(ownChainID) && nftExists) { + throw new Error('NFT substore entry already exists'); + } + } + + public async execute(context: CrossChainMessageContext): Promise { + const { ccm, getMethodContext } = context; + const params = codec.decode( + crossChainNFTTransferMessageParamsSchema, + ccm.params, + ); + validator.validate(crossChainNFTTransferMessageParamsSchema, params); + const { sendingChainID, status } = ccm; + const { nftID, senderAddress, attributesArray: receivedAttributes } = params; + const nftChainID = this._method.getChainID(nftID); + const ownChainID = this._internalMethod.getOwnChainID(); + const nftStore = this.stores.get(NFTStore); + const escrowStore = this.stores.get(EscrowStore); + let recipientAddress: Buffer; + recipientAddress = params.recipientAddress; + + if (nftChainID.equals(ownChainID)) { + const storeData = await nftStore.get(getMethodContext(), nftID); + if (status === CCM_STATUS_CODE_OK) { + storeData.owner = recipientAddress; + await nftStore.save(getMethodContext(), nftID, storeData); + await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); + await escrowStore.del(getMethodContext(), escrowStore.getKey(sendingChainID, nftID)); + } else { + recipientAddress = senderAddress; + storeData.owner = recipientAddress; + await nftStore.save(getMethodContext(), nftID, storeData); + await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); + await escrowStore.del(getMethodContext(), escrowStore.getKey(sendingChainID, nftID)); + } + } else { + const isSupported = await this._method.isNFTSupported(getMethodContext(), nftID); + if (!isSupported) { + this.events.get(CcmTransferEvent).error( + context, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_NOT_SUPPORTED, + ); + throw new Error('Non-supported NFT'); + } + if (status === CCM_STATUS_CODE_OK) { + this._feeMethod.payFee(getMethodContext(), BigInt(FEE_CREATE_NFT)); + await nftStore.save(getMethodContext(), nftID, { + owner: recipientAddress, + attributesArray: receivedAttributes as NFTAttributes[], + }); + await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); + } else { + recipientAddress = senderAddress; + await nftStore.save(getMethodContext(), nftID, { + owner: recipientAddress, + attributesArray: receivedAttributes as NFTAttributes[], + }); + await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); + } + } + + this.events.get(CcmTransferEvent).log(context, { + senderAddress, + recipientAddress, + nftID, + }); + } +} diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index a63d4095872..e732b5f17f2 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -21,6 +21,10 @@ export const LENGTH_ADDRESS = 20; export const MODULE_NAME_NFT = 'nft'; export const NFT_NOT_LOCKED = MODULE_NAME_NFT; export const CROSS_CHAIN_COMMAND_NAME_TRANSFER = 'crossChainTransfer'; +export const CCM_STATUS_CODE_OK = 0; +export const EMPTY_BYTES = Buffer.alloc(0); +export const ALL_SUPPORTED_NFTS_KEY = EMPTY_BYTES; +export const FEE_CREATE_NFT = 5000000; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, diff --git a/framework/src/modules/nft/events/ccm_transfer.ts b/framework/src/modules/nft/events/ccm_transfer.ts index 1e72b946398..990f267885b 100644 --- a/framework/src/modules/nft/events/ccm_transfer.ts +++ b/framework/src/modules/nft/events/ccm_transfer.ts @@ -58,4 +58,8 @@ export class CcmTransferEvent extends BaseEvent { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + return nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); + } + + public async isNFTSupported(methodContext: MethodContext, nftID: Buffer): Promise { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const nftChainID = this.getChainID(nftID); + if (nftChainID.equals(this._config.ownChainID)) { + return true; + } + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + const supportForAllKeysExists = await supportedNFTsStore.has( + methodContext, + ALL_SUPPORTED_NFTS_KEY, + ); + if (supportForAllKeysExists) { + return true; + } + + const supportForNftChainIdExists = await supportedNFTsStore.has(methodContext, nftChainID); + if (supportForNftChainIdExists) { + const supportedNFTsStoreData = await supportedNFTsStore.get(methodContext, nftChainID); + if (supportedNFTsStoreData.supportedCollectionIDArray.length === 0) { + return true; + } + const collectionID = await this.getCollectionID(methodContext, nftID); + if ( + supportedNFTsStoreData.supportedCollectionIDArray.some(id => + collectionID.equals(id.collectionID), + ) + ) { + return true; + } + } + + return false; + } } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index a712f07e1e0..00cadae89a7 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -43,7 +43,7 @@ export const transferParamsSchema = { export const crossChainNFTTransferMessageParamsSchema = { $id: '/lisk/crossChainNFTTransferMessageParamsSchmema', type: 'object', - required: ['nftID', 'senderAddress', 'recipientAddress', 'attributes', 'data'], + required: ['nftID', 'senderAddress', 'recipientAddress', 'attributesArray', 'data'], properties: { nftID: { dataType: 'bytes', @@ -61,7 +61,7 @@ export const crossChainNFTTransferMessageParamsSchema = { format: 'lisk32', fieldNumber: 3, }, - attributes: { + attributesArray: { type: 'array', fieldNumber: 4, items: { @@ -89,3 +89,11 @@ export const crossChainNFTTransferMessageParamsSchema = { }, }, }; + +export interface CCTransferMessageParams { + nftID: Buffer; + attributes: { module: string; attributes: Buffer }[]; + senderAddress: Buffer; + recipientAddress: Buffer; + data: string; +} diff --git a/framework/src/modules/token/cc_commands/cc_transfer.ts b/framework/src/modules/token/cc_commands/cc_transfer.ts index d07cd5b4a58..5631996e10f 100644 --- a/framework/src/modules/token/cc_commands/cc_transfer.ts +++ b/framework/src/modules/token/cc_commands/cc_transfer.ts @@ -13,7 +13,6 @@ */ import { codec } from '@liskhq/lisk-codec'; import { validator } from '@liskhq/lisk-validator'; -// import { NotFoundError } from '@liskhq/lisk-db'; import { BaseCCCommand } from '../../interoperability/base_cc_command'; import { CrossChainMessageContext } from '../../interoperability/types'; import { TokenMethod } from '../method'; diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts new file mode 100644 index 00000000000..e0fffcd6232 --- /dev/null +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -0,0 +1,615 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { codec } from '@liskhq/lisk-codec'; +import { utils } from '@liskhq/lisk-cryptography'; +import { NFTModule } from '../../../../../src/modules/nft/module'; +import { InMemoryPrefixedStateDB } from '../../../../../src/testing'; +import { + ALL_SUPPORTED_NFTS_KEY, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + FEE_CREATE_NFT, + LENGTH_CHAIN_ID, + LENGTH_NFT_ID, + NftEventResult, +} from '../../../../../src/modules/nft/constants'; +import { NFTStore } from '../../../../../src/modules/nft/stores/nft'; +import { CCMsg, CrossChainMessageContext, ccuParamsSchema } from '../../../../../src'; +import { InternalMethod } from '../../../../../src/modules/nft/internal_method'; +import { NFTMethod } from '../../../../../src/modules/nft/method'; +import { EventQueue, MethodContext, createMethodContext } from '../../../../../src/state_machine'; +import { CrossChainTransferCommand } from '../../../../../src/modules/nft/cc_commands/cc_transfer'; +import { PrefixedStateReadWriter } from '../../../../../src/state_machine/prefixed_state_read_writer'; +import { crossChainNFTTransferMessageParamsSchema } from '../../../../../src/modules/nft/schemas'; +import { + CCM_STATUS_OK, + CCM_STATUS_PROTOCOL_VIOLATION, +} from '../../../../../src/modules/token/constants'; +import { fakeLogger } from '../../../../utils/mocks/logger'; +import { CcmTransferEvent } from '../../../../../src/modules/nft/events/ccm_transfer'; +import { EscrowStore } from '../../../../../src/modules/nft/stores/escrow'; +import { UserStore } from '../../../../../src/modules/nft/stores/user'; +import { SupportedNFTsStore } from '../../../../../src/modules/nft/stores/supported_nfts'; + +describe('CrossChain Transfer Command', () => { + const module = new NFTModule(); + const method = new NFTMethod(module.stores, module.events); + const internalMethod = new InternalMethod(module.stores, module.events); + const feeMethod = { payFee: jest.fn() }; + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: any, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + const defaultAddress = utils.getRandomBytes(20); + const sendingChainID = Buffer.from([1, 1, 1, 1]); + const receivingChainID = Buffer.from([0, 0, 0, 1]); + const senderAddress = utils.getRandomBytes(20); + const recipientAddress = utils.getRandomBytes(20); + const attributesArray = [{ module: 'pos', attributes: Buffer.alloc(5) }]; + const getStore = (moduleID: Buffer, prefix: Buffer) => stateStore.getStore(moduleID, prefix); + const getMethodContext = () => methodContext; + const eventQueue = new EventQueue(0); + const contextStore = new Map(); + const nftID = Buffer.alloc(LENGTH_NFT_ID, 1); + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const ownChainID = Buffer.alloc(LENGTH_CHAIN_ID, 1); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + const interopMethod = { + send: jest.fn(), + error: jest.fn(), + terminateChain: jest.fn(), + }; + const defaultHeader = { + height: 0, + timestamp: 0, + }; + const defaultEncodedCCUParams = codec.encode(ccuParamsSchema, { + activeValidatorsUpdate: { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: Buffer.alloc(0), + }, + certificate: Buffer.alloc(1), + certificateThreshold: BigInt(1), + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes: [], + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + sendingChainID: Buffer.from('04000001', 'hex'), + }); + const defaultTransaction = { + senderAddress: defaultAddress, + fee: BigInt(0), + params: defaultEncodedCCUParams, + }; + let params: Buffer; + let ccm: CCMsg; + let command: CrossChainTransferCommand; + let methodContext: MethodContext; + let stateStore: PrefixedStateReadWriter; + let context: CrossChainMessageContext; + let nftStore: NFTStore; + let escrowStore: EscrowStore; + let userStore: UserStore; + + beforeEach(async () => { + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + method.addDependencies(interopMethod); + method.init(config); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(config); + command = new CrossChainTransferCommand(module.stores, module.events); + command.init({ method, internalMethod, feeMethod }); + methodContext = createMethodContext({ + stateStore, + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + nftStore = module.stores.get(NFTStore); + await nftStore.save(methodContext, nftID, { + owner: sendingChainID, + attributesArray: [], + }); + params = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray, + data: '', + }); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID, + }; + }); + + describe('verify', () => { + it('should resolve if verification succeeds', async () => { + await expect(command.verify(context)).resolves.toBeUndefined(); + }); + + it('throw for if validation fails', async () => { + params = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID: Buffer.alloc(LENGTH_NFT_ID + 1, 1), + senderAddress, + recipientAddress, + attributesArray, + data: '', + }); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue, + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.verify(context)).rejects.toThrow(`Property '.nftID' maxLength exceeded`); + }); + + it('throw for invalid ccm status', async () => { + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: 72, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue, + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.verify(context)).rejects.toThrow('Invalid CCM error code'); + }); + + it('throw if nft chain id is equal to neither own chain id or sending chain id', async () => { + const newConfig = { + ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(newConfig); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(newConfig); + params = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID: Buffer.alloc(LENGTH_NFT_ID, 1), + senderAddress, + recipientAddress, + attributesArray, + data: '', + }); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue, + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.verify(context)).rejects.toThrow( + 'NFT is not native to either the sending chain or the receiving chain', + ); + }); + + it('should throw if nft chain id equals own chain id but no entry exists in nft substore for the nft id', async () => { + await nftStore.del(methodContext, nftID); + + await expect(command.verify(context)).rejects.toThrow( + 'Non-existent entry in the NFT substore', + ); + }); + + it('should throw if nft chain id equals own chain id but the owner of nft is different from the sending chain', async () => { + await nftStore.del(methodContext, nftID); + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + + await expect(command.verify(context)).rejects.toThrow('NFT has not been properly escrowed'); + }); + + it('throw if nft chain id is not equal to own chain id and entry already exists in nft substore for the nft id', async () => { + const newConfig = { + ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(newConfig); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(newConfig); + + await expect(command.verify(context)).rejects.toThrow('NFT substore entry already exists'); + }); + }); + + describe('execute', () => { + beforeEach(async () => { + userStore = module.stores.get(UserStore); + escrowStore = module.stores.get(EscrowStore); + await escrowStore.set(methodContext, escrowStore.getKey(sendingChainID, nftID), {}); + }); + + it('should throw if validation fails', async () => { + params = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID: Buffer.alloc(LENGTH_NFT_ID, 1), + senderAddress: utils.getRandomBytes(32), + recipientAddress, + attributesArray, + data: '', + }); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue, + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.execute(context)).rejects.toThrow( + `Property '.senderAddress' address length invalid`, + ); + }); + + it('should throw if fail to decode the CCM', async () => { + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params: Buffer.from(''), + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue, + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.execute(context)).rejects.toThrow( + 'Message does not contain a property for fieldNumber: 1.', + ); + }); + + it('should set appropriate values to stores and emit appropriate successful ccm transfer event for nft chain id equals own chain id and ccm status code ok', async () => { + await expect(command.execute(context)).resolves.toBeUndefined(); + const nftStoreData = await nftStore.get(methodContext, nftID); + const userAccountExists = await userStore.has( + methodContext, + userStore.getKey(recipientAddress, nftID), + ); + const escrowAccountExists = await escrowStore.has( + methodContext, + escrowStore.getKey(sendingChainID, nftID), + ); + expect(nftStoreData.owner).toStrictEqual(recipientAddress); + expect(nftStoreData.attributesArray).toEqual([]); + expect(userAccountExists).toBe(true); + expect(escrowAccountExists).toBe(false); + checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { + senderAddress, + recipientAddress, + nftID, + }); + }); + + it('should set appropriate values to stores and emit appropriate successful ccm transfer event for nft chain id equals own chain id but not ccm status code ok', async () => { + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_PROTOCOL_VIOLATION, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.execute(context)).resolves.toBeUndefined(); + const nftStoreData = await nftStore.get(methodContext, nftID); + const userAccountExistsForRecipient = await userStore.has( + methodContext, + userStore.getKey(recipientAddress, nftID), + ); + const userAccountExistsForSender = await userStore.has( + methodContext, + userStore.getKey(senderAddress, nftID), + ); + const escrowAccountExists = await escrowStore.has( + methodContext, + escrowStore.getKey(sendingChainID, nftID), + ); + expect(nftStoreData.owner).toStrictEqual(senderAddress); + expect(nftStoreData.attributesArray).toEqual([]); + expect(userAccountExistsForRecipient).toBe(false); + expect(userAccountExistsForSender).toBe(true); + expect(escrowAccountExists).toBe(false); + checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { + senderAddress, + recipientAddress: senderAddress, + nftID, + }); + }); + + it('should reject and emit unsuccessful ccm transfer event if nft chain id does not equal own chain id and nft is not supported', async () => { + const newNftID = utils.getRandomBytes(LENGTH_NFT_ID); + await nftStore.save(methodContext, newNftID, { + owner: sendingChainID, + attributesArray: [], + }); + params = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID: newNftID, + senderAddress, + recipientAddress, + attributesArray, + data: '', + }); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_OK, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID, + }; + + await expect(command.execute(context)).rejects.toThrow('Non-supported NFT'); + checkEventResult( + context.eventQueue, + 1, + CcmTransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID: newNftID, + }, + NftEventResult.RESULT_NFT_NOT_SUPPORTED, + ); + }); + + it('should set appropriate values to stores and emit appropriate successful ccm transfer event if nft chain id does not equal own chain id but nft is supported and ccm status code ok', async () => { + const newConfig = { + ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(newConfig); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(newConfig); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + jest.spyOn(feeMethod, 'payFee'); + + await expect(command.execute(context)).resolves.toBeUndefined(); + const nftStoreData = await nftStore.get(methodContext, nftID); + const userAccountExists = await userStore.has( + methodContext, + userStore.getKey(recipientAddress, nftID), + ); + const escrowAccountExists = await escrowStore.has( + methodContext, + escrowStore.getKey(sendingChainID, nftID), + ); + expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); + expect(nftStoreData.owner).toStrictEqual(recipientAddress); + expect(nftStoreData.attributesArray).toEqual(attributesArray); + expect(userAccountExists).toBe(true); + expect(escrowAccountExists).toBe(true); + checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { + senderAddress, + recipientAddress, + nftID, + }); + }); + + it('should set appropriate values to stores and emit appropriate successful ccm transfer event if nft chain id does not equal own chain id but nft is supported and not ccm status code ok', async () => { + const newConfig = { + ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(newConfig); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(newConfig); + ccm = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + module: module.name, + nonce: BigInt(1), + sendingChainID, + receivingChainID, + fee: BigInt(30000), + status: CCM_STATUS_PROTOCOL_VIOLATION, + params, + }; + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID, + }; + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + jest.spyOn(feeMethod, 'payFee'); + + await expect(command.execute(context)).resolves.toBeUndefined(); + const nftStoreData = await nftStore.get(methodContext, nftID); + const userAccountExistsForRecipient = await userStore.has( + methodContext, + userStore.getKey(recipientAddress, nftID), + ); + const userAccountExistsForSender = await userStore.has( + methodContext, + userStore.getKey(senderAddress, nftID), + ); + const escrowAccountExists = await escrowStore.has( + methodContext, + escrowStore.getKey(sendingChainID, nftID), + ); + expect(feeMethod.payFee).not.toHaveBeenCalled(); + expect(nftStoreData.owner).toStrictEqual(senderAddress); + expect(nftStoreData.attributesArray).toEqual(attributesArray); + expect(userAccountExistsForRecipient).toBe(false); + expect(userAccountExistsForSender).toBe(true); + expect(escrowAccountExists).toBe(true); + checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { + senderAddress, + recipientAddress: senderAddress, + nftID, + }); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 8f227a8cb0a..7e204f73010 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -21,8 +21,10 @@ import { MethodContext, createMethodContext } from '../../../../src/state_machin import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; import { + ALL_SUPPORTED_NFTS_KEY, LENGTH_ADDRESS, LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, LENGTH_NFT_ID, NFT_NOT_LOCKED, NftEventResult, @@ -30,6 +32,7 @@ import { import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { UserStore } from '../../../../src/modules/nft/stores/user'; import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; +import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; describe('NFTMethod', () => { const module = new NFTModule(); @@ -286,4 +289,125 @@ describe('NFTMethod', () => { }); }); }); + + describe('getCollectionID', () => { + it('should throw if entry does not exist in the nft substore for the nft id', async () => { + await expect(method.getCollectionID(methodContext, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should return the first bytes of length LENGTH_CHAIN_ID from provided nftID', async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + const expectedValue = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); + const receivedValue = await method.getCollectionID(methodContext, nftID); + expect(receivedValue).toEqual(expectedValue); + }); + }); + + describe('isNFTSupported', () => { + beforeEach(async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + }); + + it('should throw if entry does not exist in the nft substore for the nft id', async () => { + await nftStore.del(methodContext, nftID); + await expect(method.isNFTSupported(methodContext, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should return true if nft chain id equals own chain id', async () => { + const ownChainID = nftID.slice(0, LENGTH_CHAIN_ID); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(config); + + const isSupported = await method.isNFTSupported(methodContext, nftID); + expect(isSupported).toBe(true); + }); + + it('should return true if nft chain id does not equal own chain id but all nft keys are supported', async () => { + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(config); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + const isSupported = await method.isNFTSupported(methodContext, nftID); + expect(isSupported).toBe(true); + }); + + it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array is empty', async () => { + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(config); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { + supportedCollectionIDArray: [], + }); + + const isSupported = await method.isNFTSupported(methodContext, nftID); + expect(isSupported).toBe(true); + }); + + it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array includes collection id for nft id', async () => { + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(config); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { + supportedCollectionIDArray: [ + { collectionID: nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID) }, + { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, + ], + }); + + const isSupported = await method.isNFTSupported(methodContext, nftID); + expect(isSupported).toBe(true); + }); + + it('should return false if nft chain id does not equal own chain id and nft chain id is supported but corresponding supported collection id array does not include collection id for nft id', async () => { + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const config = { + ownChainID, + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(config); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { + supportedCollectionIDArray: [ + { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, + { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, + ], + }); + + const isSupported = await method.isNFTSupported(methodContext, nftID); + expect(isSupported).toBe(false); + }); + }); }); From efb5b6dccb957374cbc0986cf3382d9fea8d7c56 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Tue, 6 Jun 2023 05:28:25 +0200 Subject: [PATCH 15/58] Update tests --- framework/src/modules/nft/internal_method.ts | 6 +++--- .../unit/modules/nft/cc_comands/cc_transfer.spec.ts | 10 ---------- .../test/unit/modules/nft/internal_method.spec.ts | 8 ++++---- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index dd00db0430e..faf6f94f401 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -137,10 +137,10 @@ export class InternalMethod extends BaseMethod { await this._method.destroy(methodContext, senderAddress, nftID); } - let attributes: { module: string; attributes: Buffer }[] = []; + let attributesArray: { module: string; attributes: Buffer }[] = []; if (includeAttributes) { - attributes = nft.attributesArray; + attributesArray = nft.attributesArray; } this.events.get(TransferCrossChainEvent).log(methodContext, { @@ -163,7 +163,7 @@ export class InternalMethod extends BaseMethod { nftID, senderAddress, recipientAddress, - attributes, + attributesArray, data, }), ); diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index e0fffcd6232..5c005cbfb95 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -532,15 +532,10 @@ describe('CrossChain Transfer Command', () => { methodContext, userStore.getKey(recipientAddress, nftID), ); - const escrowAccountExists = await escrowStore.has( - methodContext, - escrowStore.getKey(sendingChainID, nftID), - ); expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); expect(nftStoreData.owner).toStrictEqual(recipientAddress); expect(nftStoreData.attributesArray).toEqual(attributesArray); expect(userAccountExists).toBe(true); - expect(escrowAccountExists).toBe(true); checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { senderAddress, recipientAddress, @@ -595,16 +590,11 @@ describe('CrossChain Transfer Command', () => { methodContext, userStore.getKey(senderAddress, nftID), ); - const escrowAccountExists = await escrowStore.has( - methodContext, - escrowStore.getKey(sendingChainID, nftID), - ); expect(feeMethod.payFee).not.toHaveBeenCalled(); expect(nftStoreData.owner).toStrictEqual(senderAddress); expect(nftStoreData.attributesArray).toEqual(attributesArray); expect(userAccountExistsForRecipient).toBe(false); expect(userAccountExistsForSender).toBe(true); - expect(escrowAccountExists).toBe(true); checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { senderAddress, recipientAddress: senderAddress, diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 6b2d6a79f6a..270a29d3786 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -211,7 +211,7 @@ describe('InternalMethod', () => { nftID, senderAddress, recipientAddress, - attributes: [], + attributesArray: [], data, }); @@ -288,7 +288,7 @@ describe('InternalMethod', () => { nftID, senderAddress, recipientAddress, - attributes: [], + attributesArray: [], data, }); @@ -366,7 +366,7 @@ describe('InternalMethod', () => { nftID, senderAddress, recipientAddress, - attributes: attributesArray, + attributesArray, data, }); @@ -450,7 +450,7 @@ describe('InternalMethod', () => { nftID, senderAddress, recipientAddress, - attributes: attributesArray, + attributesArray, data, }); From eff844a505cb85fd8407dbbffa8f5912cf976366 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:34:39 +0200 Subject: [PATCH 16/58] Add internal function per feedback --- framework/src/modules/nft/cc_commands/cc_transfer.ts | 2 ++ framework/src/modules/nft/internal_method.ts | 9 +++++++++ framework/src/modules/nft/schemas.ts | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts index e172160896e..9a2331926d0 100644 --- a/framework/src/modules/nft/cc_commands/cc_transfer.ts +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -104,6 +104,8 @@ export class CrossChainTransferCommand extends BaseCCCommand { const storeData = await nftStore.get(getMethodContext(), nftID); if (status === CCM_STATUS_CODE_OK) { storeData.owner = recipientAddress; + // commented line below can be used by custom modules when defining their own logic for getNewAttributes function + // storeData.attributesArray = this._internalMethod.getNewAttributes(nftID, storeData.attributesArray, params.attributesArray); await nftStore.save(getMethodContext(), nftID, storeData); await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); await escrowStore.del(getMethodContext(), escrowStore.getKey(sendingChainID, nftID)); diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index faf6f94f401..2af97f56df5 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -172,4 +172,13 @@ export class InternalMethod extends BaseMethod { public getOwnChainID(): Buffer { return this._config.ownChainID; } + + // template for custom module to be able to define their own logic as described in https://github.com/LiskHQ/lips/blob/main/proposals/lip-0052.md#attributes + public getNewAttributes( + _nftID: Buffer, + storedAttributes: NFTAttributes[], + _receivedAttributes: NFTAttributes[], + ): NFTAttributes[] { + return storedAttributes; + } } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 00cadae89a7..4bccf862491 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -92,7 +92,7 @@ export const crossChainNFTTransferMessageParamsSchema = { export interface CCTransferMessageParams { nftID: Buffer; - attributes: { module: string; attributes: Buffer }[]; + attributesArray: { module: string; attributes: Buffer }[]; senderAddress: Buffer; recipientAddress: Buffer; data: string; From 0ae50b12b495ca319d9bca4238d45aa8dfe18d46 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Wed, 7 Jun 2023 12:13:41 +0200 Subject: [PATCH 17/58] transferCrossChainInternal Method for NFT Module (#8520) * :seedling: Adds EscrowStore#getKey * :bug: Fixes schema for DestroyEvent * :recycle: Adds result parameter to DestroyEvent#log * :seedling: Adds NFTMethod.getChainID * :seedling: Adds NFTMethod.destroy * :seedling: Adds InternalMethod.createEscrowEntry * :recycle: test for InternalMethod * :seedling: Adds InternalMethod.transferCrossChainInternal * :recycle: /nft/crossChainNFTTransferMessageParamsSchema * :recycle: specs for NFTMethod * :recycle: NFTMethod.destroy * :recycle: NFTMethod.destroy consumes NFTMethod.getLockingModule * :rewind: NFTMethod.destroy consumes NFTMethod.getLockingModule * :white_check_mark: for NFTMethod.destroy * :recycle: :white_check_mark: for NFTMethod.destroy Co-authored-by: Incede <33103370+Incede@users.noreply.github.com> --------- Co-authored-by: Incede <33103370+Incede@users.noreply.github.com> --- framework/src/modules/nft/constants.ts | 4 +- framework/src/modules/nft/events/destroy.ts | 13 +- framework/src/modules/nft/internal_method.ts | 91 ++++- framework/src/modules/nft/method.ts | 90 ++++- framework/src/modules/nft/schemas.ts | 52 ++- framework/src/modules/nft/stores/escrow.ts | 4 + framework/src/modules/nft/types.ts | 4 +- .../unit/modules/nft/internal_method.spec.ts | 370 +++++++++++++++++- .../test/unit/modules/nft/method.spec.ts | 183 ++++++++- .../unit/modules/nft/stores/escrow.spec.ts | 36 ++ 10 files changed, 824 insertions(+), 23 deletions(-) create mode 100644 framework/test/unit/modules/nft/stores/escrow.spec.ts diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index 323e8dfc67f..a63d4095872 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -18,7 +18,9 @@ export const LENGTH_COLLECTION_ID = 4; export const MIN_LENGTH_MODULE_NAME = 1; export const MAX_LENGTH_MODULE_NAME = 32; export const LENGTH_ADDRESS = 20; -export const NFT_NOT_LOCKED = 'nft'; +export const MODULE_NAME_NFT = 'nft'; +export const NFT_NOT_LOCKED = MODULE_NAME_NFT; +export const CROSS_CHAIN_COMMAND_NAME_TRANSFER = 'crossChainTransfer'; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, diff --git a/framework/src/modules/nft/events/destroy.ts b/framework/src/modules/nft/events/destroy.ts index 1294f466ba9..3475c03a869 100644 --- a/framework/src/modules/nft/events/destroy.ts +++ b/framework/src/modules/nft/events/destroy.ts @@ -33,7 +33,7 @@ export const createEventSchema = { nftID: { dataType: 'bytes', minLength: LENGTH_NFT_ID, - maxLenght: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, fieldNumber: 2, }, result: { @@ -46,10 +46,11 @@ export const createEventSchema = { export class DestroyEvent extends BaseEvent { public schema = createEventSchema; - public log(ctx: EventQueuer, data: DestroyEventData): void { - this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ - data.address, - data.nftID, - ]); + public log( + ctx: EventQueuer, + data: DestroyEventData, + result: NftEventResult = NftEventResult.RESULT_SUCCESSFUL, + ): void { + this.add(ctx, { ...data, result }, [data.address, data.nftID]); } } diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index 16f21c62cb1..c50a2ac36d8 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -12,23 +12,23 @@ * Removal or modification of this copyright notice is prohibited. */ +import { codec } from '@liskhq/lisk-codec'; import { BaseMethod } from '../base_method'; import { NFTStore, NFTAttributes } from './stores/nft'; import { InteroperabilityMethod, ModuleConfig } from './types'; import { MethodContext } from '../../state_machine'; import { TransferEvent } from './events/transfer'; import { UserStore } from './stores/user'; -import { NFT_NOT_LOCKED } from './constants'; +import { CROSS_CHAIN_COMMAND_NAME_TRANSFER, MODULE_NAME_NFT, NFT_NOT_LOCKED } from './constants'; import { NFTMethod } from './method'; +import { EscrowStore } from './stores/escrow'; +import { TransferCrossChainEvent } from './events/transfer_cross_chain'; +import { CCM_STATUS_OK } from '../token/constants'; +import { crossChainNFTTransferMessageParamsSchema } from './schemas'; export class InternalMethod extends BaseMethod { - // @ts-expect-error TODO: unused error. Remove when implementing. private _config!: ModuleConfig; - - // @ts-expect-error TODO: unused error. Remove when implementing. private _method!: NFTMethod; - - // @ts-expect-error TODO: unused error. Remove when implementing. private _interoperabilityMethod!: InteroperabilityMethod; public init(config: ModuleConfig): void { @@ -40,6 +40,16 @@ export class InternalMethod extends BaseMethod { this._interoperabilityMethod = interoperabilityMethod; } + public async createEscrowEntry( + methodContext: MethodContext, + receivingChainID: Buffer, + nftID: Buffer, + ): Promise { + const escrowStore = this.stores.get(EscrowStore); + + await escrowStore.set(methodContext, escrowStore.getKey(receivingChainID, nftID), {}); + } + public async createUserEntry( methodContext: MethodContext, address: Buffer, @@ -89,4 +99,73 @@ export class InternalMethod extends BaseMethod { nftID, }); } + + public async transferCrossChainInternal( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + receivingChainID: Buffer, + messageFee: bigint, + data: string, + includeAttributes: boolean, + ): Promise { + const chainID = this._method.getChainID(nftID); + const nftStore = this.stores.get(NFTStore); + const nft = await nftStore.get(methodContext, nftID); + + if (chainID.equals(this._config.ownChainID)) { + const escrowStore = this.stores.get(EscrowStore); + const userStore = this.stores.get(UserStore); + + nft.owner = receivingChainID; + await nftStore.save(methodContext, nftID, nft); + + await userStore.del(methodContext, userStore.getKey(senderAddress, nftID)); + + const escrowExists = await escrowStore.has( + methodContext, + escrowStore.getKey(receivingChainID, nftID), + ); + + if (!escrowExists) { + await this.createEscrowEntry(methodContext, receivingChainID, nftID); + } + } + + if (chainID.equals(receivingChainID)) { + await this._method.destroy(methodContext, senderAddress, nftID); + } + + let attributesArray: { module: string; attributes: Buffer }[] = []; + + if (includeAttributes) { + attributesArray = nft.attributesArray; + } + + this.events.get(TransferCrossChainEvent).log(methodContext, { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }); + + await this._interoperabilityMethod.send( + methodContext, + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray, + data, + }), + ); + } } diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index cccadfa8523..502812ea3ff 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -14,9 +14,10 @@ import { BaseMethod } from '../base_method'; import { InteroperabilityMethod, ModuleConfig } from './types'; import { NFTStore } from './stores/nft'; -import { ImmutableMethodContext } from '../../state_machine'; -import { LENGTH_CHAIN_ID } from './constants'; +import { ImmutableMethodContext, MethodContext } from '../../state_machine'; +import { LENGTH_CHAIN_ID, LENGTH_NFT_ID, NFT_NOT_LOCKED, NftEventResult } from './constants'; import { UserStore } from './stores/user'; +import { DestroyEvent } from './events/destroy'; export class NFTMethod extends BaseMethod { // @ts-expect-error TODO: unused error. Remove when implementing. @@ -32,6 +33,14 @@ export class NFTMethod extends BaseMethod { this._interoperabilityMethod = interoperabilityMethod; } + public getChainID(nftID: Buffer): Buffer { + if (nftID.length !== LENGTH_NFT_ID) { + throw new Error(`NFT ID must have length ${LENGTH_NFT_ID}`); + } + + return nftID.slice(0, LENGTH_CHAIN_ID); + } + public async getNFTOwner(methodContext: ImmutableMethodContext, nftID: Buffer): Promise { const nftStore = this.stores.get(NFTStore); @@ -61,4 +70,81 @@ export class NFTMethod extends BaseMethod { return userData.lockingModule; } + + public async destroy( + methodContext: MethodContext, + address: Buffer, + nftID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(methodContext, nftID); + + if (!nftExists) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(address)) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + + throw new Error('Not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userKey = userStore.getKey(owner, nftID); + const { lockingModule } = await userStore.get(methodContext, userKey); + + if (lockingModule !== NFT_NOT_LOCKED) { + this.events.get(DestroyEvent).log( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + + throw new Error('Locked NFTs cannot be destroyed'); + } + + await nftStore.del(methodContext, nftID); + + await userStore.del(methodContext, userKey); + + this.events.get(DestroyEvent).log(methodContext, { + address, + nftID, + }); + } } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 2c0cee0da4e..100bd5c1e15 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -13,7 +13,7 @@ */ import { MAX_DATA_LENGTH } from '../token/constants'; -import { LENGTH_NFT_ID } from './constants'; +import { LENGTH_NFT_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from './constants'; export const transferParamsSchema = { $id: '/lisk/nftTransferParams', @@ -39,3 +39,53 @@ export const transferParamsSchema = { }, }, }; + +export const crossChainNFTTransferMessageParamsSchema = { + $id: '/lisk/crossChainNFTTransferMessageParamsSchmema', + type: 'object', + required: ['nftID', 'senderAddress', 'recipientAddress', 'attributesArray', 'data'], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + senderAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 2, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 3, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + data: { + dataType: 'string', + maxLength: MAX_DATA_LENGTH, + fieldNumber: 5, + }, + }, +}; diff --git a/framework/src/modules/nft/stores/escrow.ts b/framework/src/modules/nft/stores/escrow.ts index 719bf0b7fbe..b5d224088bd 100644 --- a/framework/src/modules/nft/stores/escrow.ts +++ b/framework/src/modules/nft/stores/escrow.ts @@ -25,4 +25,8 @@ type EscrowStoreData = Record; export class EscrowStore extends BaseStore { public schema = escrowStoreSchema; + + public getKey(receivingChainID: Buffer, nftID: Buffer): Buffer { + return Buffer.concat([receivingChainID, nftID]); + } } diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 40fa051c2f8..74d123c56aa 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -16,7 +16,9 @@ import { MethodContext } from '../../state_machine'; import { CCMsg } from '../interoperability'; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ModuleConfig {} +export interface ModuleConfig { + ownChainID: Buffer; +} export interface InteroperabilityMethod { send( diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 2e46a80553c..270a29d3786 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -20,26 +20,46 @@ import { EventQueue, createMethodContext } from '../../../../src/state_machine'; import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; import { + CROSS_CHAIN_COMMAND_NAME_TRANSFER, LENGTH_ADDRESS, + LENGTH_CHAIN_ID, LENGTH_NFT_ID, + MODULE_NAME_NFT, NFT_NOT_LOCKED, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { MethodContext } from '../../../../src/state_machine/method_context'; -import { TransferEvent } from '../../../../src/modules/nft/events/transfer'; +import { TransferEvent, TransferEventData } from '../../../../src/modules/nft/events/transfer'; import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; +import { NFTMethod } from '../../../../src/modules/nft/method'; +import { InteroperabilityMethod } from '../../../../src/modules/nft/types'; +import { + TransferCrossChainEvent, + TransferCrossChainEventData, +} from '../../../../src/modules/nft/events/transfer_cross_chain'; +import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; +import { CCM_STATUS_OK } from '../../../../src/modules/token/constants'; +import { crossChainNFTTransferMessageParamsSchema } from '../../../../src/modules/nft/schemas'; describe('InternalMethod', () => { const module = new NFTModule(); const internalMethod = new InternalMethod(module.stores, module.events); + const method = new NFTMethod(module.stores, module.events); + let interoperabilityMethod!: InteroperabilityMethod; + internalMethod.addDependencies(method, interoperabilityMethod); + + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + internalMethod.init({ ownChainID }); + let methodContext!: MethodContext; - const checkEventResult = ( + const checkEventResult = ( eventQueue: EventQueue, length: number, EventClass: any, index: number, - expectedResult: any, + expectedResult: EventDataType, result: any = 0, ) => { expect(eventQueue.getEvents()).toHaveLength(length); @@ -55,11 +75,12 @@ describe('InternalMethod', () => { const userStore = module.stores.get(UserStore); const nftStore = module.stores.get(NFTStore); + const escrowStore = module.stores.get(EscrowStore); const address = utils.getRandomBytes(LENGTH_ADDRESS); const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + let nftID = utils.getRandomBytes(LENGTH_NFT_ID); beforeEach(() => { methodContext = createMethodContext({ @@ -69,6 +90,18 @@ describe('InternalMethod', () => { }); }); + describe('createEscrowEntry', () => { + it('should create an entry in EscrowStore', async () => { + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await internalMethod.createEscrowEntry(methodContext, receivingChainID, nftID); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + }); + }); + describe('createNFTEntry', () => { it('should create an entry in NFStore with attributes sorted by module', async () => { const unsortedAttributesArray = [ @@ -137,7 +170,7 @@ describe('InternalMethod', () => { lockingModule: NFT_NOT_LOCKED, }); - checkEventResult(methodContext.eventQueue, 1, TransferEvent, 0, { + checkEventResult(methodContext.eventQueue, 1, TransferEvent, 0, { senderAddress, recipientAddress, nftID, @@ -150,4 +183,331 @@ describe('InternalMethod', () => { ).rejects.toThrow('does not exist'); }); }); + + describe('transferCrossChainInternal', () => { + let receivingChainID: Buffer; + const messageFee = BigInt(1000); + const data = ''; + + beforeEach(() => { + receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + interoperabilityMethod = { + send: jest.fn().mockResolvedValue(Promise.resolve()), + error: jest.fn().mockResolvedValue(Promise.resolve()), + terminateChain: jest.fn().mockRejectedValue(Promise.resolve()), + }; + + internalMethod.addDependencies(method, interoperabilityMethod); + }); + + describe('if attributes are not included ccm contains empty attributes', () => { + const includeAttributes = false; + + it('should transfer the ownership of the NFT to the receiving chain and escrow it for a native NFT', async () => { + const chainID = ownChainID; + nftID = Buffer.concat([chainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray: [], + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + await expect(nftStore.get(methodContext, nftID)).resolves.toEqual({ + owner: receivingChainID, + attributesArray: [], + }); + + await expect( + userStore.has(methodContext, userStore.getKey(senderAddress, nftID)), + ).resolves.toBeFalse(); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + + it('should destroy NFT if the chain ID of the NFT is the same as receiving chain', async () => { + nftID = Buffer.concat([ + receivingChainID, + utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID), + ]); + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray: [], + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + checkEventResult(methodContext.eventQueue, 2, DestroyEvent, 0, { + address: senderAddress, + nftID, + }); + + checkEventResult( + methodContext.eventQueue, + 2, + TransferCrossChainEvent, + 1, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + }); + + describe('if attributes are included ccm contains attributes of the NFT', () => { + const includeAttributes = true; + + it('should transfer the ownership of the NFT to the receiving chain and escrow it for a native NFT', async () => { + const chainID = ownChainID; + nftID = Buffer.concat([chainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + const attributesArray = [ + { + module: 'pos', + attributes: utils.getRandomBytes(20), + }, + ]; + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray, + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + await expect(nftStore.get(methodContext, nftID)).resolves.toEqual({ + owner: receivingChainID, + attributesArray, + }); + + await expect( + userStore.has(methodContext, userStore.getKey(senderAddress, nftID)), + ).resolves.toBeFalse(); + + await expect( + escrowStore.get(methodContext, escrowStore.getKey(receivingChainID, nftID)), + ).resolves.toEqual({}); + + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + + it('should destroy NFT if the chain ID of the NFT is the same as receiving chain', async () => { + nftID = Buffer.concat([ + receivingChainID, + utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID), + ]); + + const attributesArray = [ + { + module: 'pos', + attributes: utils.getRandomBytes(20), + }, + ]; + + const ccmParameters = codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray, + data, + }); + + await nftStore.save(methodContext, nftID, { + owner: senderAddress, + attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(senderAddress, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await expect( + internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + + checkEventResult(methodContext.eventQueue, 2, DestroyEvent, 0, { + address: senderAddress, + nftID, + }); + + checkEventResult( + methodContext.eventQueue, + 2, + TransferCrossChainEvent, + 1, + { + senderAddress, + recipientAddress, + nftID, + receivingChainID, + includeAttributes, + }, + ); + + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenCalledOnce(); + expect(internalMethod['_interoperabilityMethod'].send).toHaveBeenNthCalledWith( + 1, + expect.anything(), + senderAddress, + MODULE_NAME_NFT, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + receivingChainID, + messageFee, + CCM_STATUS_OK, + ccmParameters, + ); + }); + }); + }); }); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 346f7473a59..ecd66c39a33 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -12,6 +12,7 @@ * Removal or modification of this copyright notice is prohibited. */ +import { codec } from '@liskhq/lisk-codec'; import { utils } from '@liskhq/lisk-cryptography'; import { NFTMethod } from '../../../../src/modules/nft/method'; import { NFTModule } from '../../../../src/modules/nft/module'; @@ -23,11 +24,14 @@ import { LENGTH_ADDRESS, LENGTH_CHAIN_ID, LENGTH_NFT_ID, + NFT_NOT_LOCKED, + NftEventResult, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; -describe('NFTMethods', () => { +describe('NFTMethod', () => { const module = new NFTModule(); const method = new NFTMethod(module.stores, module.events); @@ -39,6 +43,25 @@ describe('NFTMethods', () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); let owner: Buffer; + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: EventDataType, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + beforeEach(() => { owner = utils.getRandomBytes(LENGTH_ADDRESS); @@ -49,6 +72,18 @@ describe('NFTMethods', () => { }); }); + describe('getChainID', () => { + it('should throw if nftID has invalid length', () => { + expect(() => { + method.getChainID(utils.getRandomBytes(LENGTH_NFT_ID - 1)); + }).toThrow(`NFT ID must have length ${LENGTH_NFT_ID}`); + }); + + it('should return the first bytes of length LENGTH_CHAIN_ID from provided nftID', () => { + expect(method.getChainID(nftID)).toEqual(nftID.slice(0, LENGTH_CHAIN_ID)); + }); + }); + describe('getNFTOwner', () => { it('should fail if NFT does not exist', async () => { await expect(method.getNFTOwner(methodContext, nftID)).rejects.toThrow( @@ -101,4 +136,150 @@ describe('NFTMethods', () => { await expect(method.getLockingModule(methodContext, nftID)).resolves.toEqual(lockingModule); }); }); + + describe('destroy', () => { + let existingNFT: { nftID: any; owner: any }; + let lockedExistingNFT: { nftID: any; owner: any }; + let escrowedNFT: { nftID: any; owner: any }; + + beforeEach(async () => { + existingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + lockedExistingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + escrowedNFT = { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + await nftStore.save(methodContext, existingNFT.nftID, { + owner: existingNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await nftStore.save(methodContext, lockedExistingNFT.nftID, { + owner: lockedExistingNFT.owner, + attributesArray: [], + }); + + await userStore.set( + methodContext, + userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), + { + lockingModule: 'token', + }, + ); + + await nftStore.save(methodContext, escrowedNFT.nftID, { + owner: escrowedNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(escrowedNFT.owner, escrowedNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + }); + + it('should fail and emit Destroy event if NFT does not exist', async () => { + const address = utils.getRandomBytes(LENGTH_ADDRESS); + + await expect(method.destroy(methodContext, address, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should fail and emit Destroy event if NFT is not owned by the provided address', async () => { + const notOwner = utils.getRandomBytes(LENGTH_ADDRESS); + + await expect(method.destroy(methodContext, notOwner, existingNFT.nftID)).rejects.toThrow( + 'Not initiated by the NFT owner', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: notOwner, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should fail and emit Destroy event if NFT is escrowed', async () => { + await expect( + method.destroy(methodContext, escrowedNFT.owner, escrowedNFT.nftID), + ).rejects.toThrow('NFT is escrowed to another chain'); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: escrowedNFT.owner, + nftID: escrowedNFT.nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should fail and emit Destroy event if NFT is locked', async () => { + await expect( + method.destroy(methodContext, lockedExistingNFT.owner, lockedExistingNFT.nftID), + ).rejects.toThrow('Locked NFTs cannot be destroyed'); + + checkEventResult( + methodContext.eventQueue, + 1, + DestroyEvent, + 0, + { + address: lockedExistingNFT.owner, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should delete NFTStore and UserStore entry and emit Destroy event', async () => { + await expect( + method.destroy(methodContext, existingNFT.owner, existingNFT.nftID), + ).resolves.toBeUndefined(); + + await expect(nftStore.has(methodContext, existingNFT.nftID)).resolves.toBeFalse(); + await expect( + userStore.has(methodContext, Buffer.concat([existingNFT.owner, escrowedNFT.nftID])), + ).resolves.toBeFalse(); + + checkEventResult(methodContext.eventQueue, 1, DestroyEvent, 0, { + address: existingNFT.owner, + nftID: existingNFT.nftID, + }); + }); + }); }); diff --git a/framework/test/unit/modules/nft/stores/escrow.spec.ts b/framework/test/unit/modules/nft/stores/escrow.spec.ts new file mode 100644 index 00000000000..89d27e973af --- /dev/null +++ b/framework/test/unit/modules/nft/stores/escrow.spec.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { EscrowStore } from '../../../../../src/modules/nft/stores/escrow'; +import { LENGTH_CHAIN_ID, LENGTH_NFT_ID } from '../../../../../src/modules/nft/constants'; + +describe('EscrowStore', () => { + let store: EscrowStore; + + beforeEach(() => { + store = new EscrowStore('NFT', 5); + }); + + describe('getKey', () => { + it('should concatenate the provided receivingChainID and nftID', () => { + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + expect(store.getKey(receivingChainID, nftID)).toEqual( + Buffer.concat([receivingChainID, nftID]), + ); + }); + }); +}); From 30ebd4480a786d2cd0c0e450968f77c6e90d3550 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 8 Jun 2023 00:17:10 +0200 Subject: [PATCH 18/58] Implement nft methods --- framework/src/modules/nft/events/create.ts | 4 +- framework/src/modules/nft/method.ts | 98 +++++++++- framework/src/modules/nft/module.ts | 4 +- .../nft/cc_comands/cc_transfer.spec.ts | 2 +- .../test/unit/modules/nft/method.spec.ts | 184 ++++++++++++++++++ 5 files changed, 284 insertions(+), 8 deletions(-) diff --git a/framework/src/modules/nft/events/create.ts b/framework/src/modules/nft/events/create.ts index c14b93d1a88..be3f55ae96c 100644 --- a/framework/src/modules/nft/events/create.ts +++ b/framework/src/modules/nft/events/create.ts @@ -34,13 +34,13 @@ export const createEventSchema = { nftID: { dataType: 'bytes', minLength: LENGTH_NFT_ID, - maxLenght: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, fieldNumber: 2, }, collectionID: { dataType: 'bytes', minLength: LENGTH_COLLECTION_ID, - maxLenght: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, fieldNumber: 3, }, result: { diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index a807e9ad674..83c2593982a 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -12,11 +12,12 @@ * Removal or modification of this copyright notice is prohibited. */ import { BaseMethod } from '../base_method'; -import { InteroperabilityMethod, ModuleConfig } from './types'; -import { NFTStore } from './stores/nft'; +import { FeeMethod, InteroperabilityMethod, ModuleConfig } from './types'; +import { NFTAttributes, NFTStore } from './stores/nft'; import { ImmutableMethodContext, MethodContext } from '../../state_machine'; import { ALL_SUPPORTED_NFTS_KEY, + FEE_CREATE_NFT, LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, LENGTH_NFT_ID, @@ -26,18 +27,21 @@ import { import { UserStore } from './stores/user'; import { DestroyEvent } from './events/destroy'; import { SupportedNFTsStore } from './stores/supported_nfts'; +import { CreateEvent } from './events/create'; export class NFTMethod extends BaseMethod { private _config!: ModuleConfig; // @ts-expect-error TODO: unused error. Remove when implementing. private _interoperabilityMethod!: InteroperabilityMethod; + private _feeMethod!: FeeMethod; public init(config: ModuleConfig): void { this._config = config; } - public addDependencies(interoperabilityMethod: InteroperabilityMethod) { + public addDependencies(interoperabilityMethod: InteroperabilityMethod, feeMethod: FeeMethod) { this._interoperabilityMethod = interoperabilityMethod; + this._feeMethod = feeMethod; } public getChainID(nftID: Buffer): Buffer { @@ -203,4 +207,92 @@ export class NFTMethod extends BaseMethod { return false; } + + public async getAttributesArray( + methodContext: MethodContext, + nftID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const storeData = await nftStore.get(methodContext, nftID); + return storeData.attributesArray; + } + + public async getAttributes( + methodContext: MethodContext, + module: string, + nftID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const storeData = await nftStore.get(methodContext, nftID); + + for (const nftAttributes of storeData.attributesArray) { + if (nftAttributes.module === module) { + return nftAttributes.attributes; + } + } + + throw new Error('Specific module did not set any attributes.'); + } + + public async getNextAvailableIndex( + methodContext: MethodContext, + collectionID: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const nftStoreData = await nftStore.iterate(methodContext, { + gte: Buffer.alloc(LENGTH_NFT_ID, 0), + lte: Buffer.alloc(LENGTH_NFT_ID, 255), + }); + + let count = 0; + for (const { key } of nftStoreData) { + if (key.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID).equals(collectionID)) { + count += 1; + } + } + + return count; + } + + public async create( + methodContext: MethodContext, + address: Buffer, + collectionID: Buffer, + attributesArray: NFTAttributes[], + ): Promise { + const index = await this.getNextAvailableIndex(methodContext, collectionID); + const nftID = Buffer.concat([ + this._config.ownChainID, + collectionID, + Buffer.from(index.toString()), + ]); + this._feeMethod.payFee(methodContext, BigInt(FEE_CREATE_NFT)); + + const nftStore = this.stores.get(NFTStore); + await nftStore.save(methodContext, nftID, { + owner: address, + attributesArray, + }); + + const userStore = this.stores.get(UserStore); + await userStore.set(methodContext, userStore.getKey(address, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + this.events.get(CreateEvent).log(methodContext, { + address, + nftID, + collectionID, + }); + } } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 62dd39ab424..5518b54092d 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -84,9 +84,9 @@ export class NFTModule extends BaseInteroperableModule { this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); } - public addDependencies(interoperabilityMethod: InteroperabilityMethod, _feeMethod: FeeMethod) { + public addDependencies(interoperabilityMethod: InteroperabilityMethod, feeMethod: FeeMethod) { this._interoperabilityMethod = interoperabilityMethod; - this.method.addDependencies(interoperabilityMethod); + this.method.addDependencies(interoperabilityMethod, feeMethod); this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); this.crossChainMethod.addDependencies(interoperabilityMethod); } diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index 5c005cbfb95..0e09de2bf26 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -127,7 +127,7 @@ describe('CrossChain Transfer Command', () => { beforeEach(async () => { stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); - method.addDependencies(interopMethod); + method.addDependencies(interopMethod, feeMethod); method.init(config); internalMethod.addDependencies(method, interopMethod); internalMethod.init(config); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 8225174ba30..cea60f266f4 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -22,6 +22,7 @@ import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_ import { InMemoryPrefixedStateDB } from '../../../../src/testing/in_memory_prefixed_state'; import { ALL_SUPPORTED_NFTS_KEY, + FEE_CREATE_NFT, LENGTH_ADDRESS, LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, @@ -33,6 +34,7 @@ import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { UserStore } from '../../../../src/modules/nft/stores/user'; import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; +import { CreateEvent } from '../../../../src/modules/nft/events/create'; describe('NFTMethod', () => { const module = new NFTModule(); @@ -406,4 +408,186 @@ describe('NFTMethod', () => { expect(isSupported).toBe(false); }); }); + + describe('getAttributesArray', () => { + const expectedAttributesArray = [ + { module: 'customMod1', attributes: Buffer.alloc(5) }, + { module: 'customMod2', attributes: Buffer.alloc(2) }, + ]; + + it('should throw if entry does not exist in the nft substore for the nft id', async () => { + await expect(method.getAttributesArray(methodContext, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should return attributes array if entry exists in the nft substore for the nft id', async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: expectedAttributesArray, + }); + const returnedAttributesArray = await method.getAttributesArray(methodContext, nftID); + expect(returnedAttributesArray).toStrictEqual(expectedAttributesArray); + }); + }); + + describe('getAttributes', () => { + const module1 = 'customMod1'; + const module2 = 'customMod2'; + const module3 = 'customMod3'; + const expectedAttributesArray = [ + { module: module1, attributes: Buffer.alloc(5) }, + { module: module2, attributes: Buffer.alloc(2) }, + ]; + + beforeEach(async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: expectedAttributesArray, + }); + }); + + it('should throw if entry does not exist in the nft substore for the nft id', async () => { + await nftStore.del(methodContext, nftID); + await expect(method.getAttributes(methodContext, module1, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + }); + + it('should return attributes if entry exists in the nft substore for the nft id and attributes exists for the requested module', async () => { + const returnedAttributes = await method.getAttributes(methodContext, module1, nftID); + expect(returnedAttributes).toStrictEqual(expectedAttributesArray[0].attributes); + }); + + it('should throw if entry exists in the nft substore for the nft id but no attributes exists for the requested module', async () => { + await expect(method.getAttributes(methodContext, module3, nftID)).rejects.toThrow( + 'Specific module did not set any attributes.', + ); + }); + }); + + describe('getNextAvailableIndex', () => { + const attributesArray1 = [ + { module: 'customMod1', attributes: Buffer.alloc(5) }, + { module: 'customMod2', attributes: Buffer.alloc(2) }, + ]; + const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; + const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); + + beforeEach(async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray1, + }); + }); + + it('should return index count 0 if entry does not exist in the nft substore for the nft id', async () => { + await nftStore.del(methodContext, nftID); + const returnedIndex = await method.getNextAvailableIndex( + methodContext, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ); + expect(returnedIndex).toBe(0); + }); + + it('should return index count 0 if entry exists in the nft substore for the nft id and no key matches the given collection id', async () => { + const returnedIndex = await method.getNextAvailableIndex( + methodContext, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ); + expect(returnedIndex).toBe(0); + }); + + it('should return index count 1 if entry exists in the nft substore for the nft id and a key matches the given collection id', async () => { + const returnedIndex = await method.getNextAvailableIndex(methodContext, collectionID); + expect(returnedIndex).toBe(1); + }); + + it('should return non zero index count if entry exists in the nft substore for the nft id and more than 1 key matches the given collection id', async () => { + const newKey = Buffer.concat([utils.getRandomBytes(LENGTH_CHAIN_ID), collectionID]); + await nftStore.save(methodContext, newKey, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray2, + }); + const returnedIndex = await method.getNextAvailableIndex(methodContext, collectionID); + expect(returnedIndex).toBe(2); + }); + }); + + describe('create', () => { + const interopMethod = { + send: jest.fn(), + error: jest.fn(), + terminateChain: jest.fn(), + }; + const feeMethod = { payFee: jest.fn() }; + const attributesArray1 = [ + { module: 'customMod1', attributes: Buffer.alloc(5) }, + { module: 'customMod2', attributes: Buffer.alloc(2) }, + ]; + const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; + const attributesArray3 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; + const config = { + ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); + const address = utils.getRandomBytes(LENGTH_ADDRESS); + + beforeEach(() => { + method.addDependencies(interopMethod, feeMethod); + method.init(config); + jest.spyOn(feeMethod, 'payFee'); + }); + + it('should set data to stores with correct key and emit successfull create event when there is no entry in the nft substore', async () => { + const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('0')]); + + await method.create(methodContext, address, collectionID, attributesArray3); + const nftStoreData = await nftStore.get(methodContext, expectedKey); + const userStoreData = await userStore.get( + methodContext, + userStore.getKey(address, expectedKey), + ); + expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); + expect(nftStoreData.owner).toStrictEqual(address); + expect(nftStoreData.attributesArray).toEqual(attributesArray3); + expect(userStoreData.lockingModule).toEqual(NFT_NOT_LOCKED); + checkEventResult(methodContext.eventQueue, 1, CreateEvent, 0, { + address, + nftID: expectedKey, + collectionID, + }); + }); + + it('should set data to stores with correct key and emit successfull create event when there is some entry in the nft substore', async () => { + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray1, + }); + const newKey = Buffer.concat([utils.getRandomBytes(LENGTH_CHAIN_ID), collectionID]); + await nftStore.save(methodContext, newKey, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray2, + }); + const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('2')]); + + await method.create(methodContext, address, collectionID, attributesArray3); + const nftStoreData = await nftStore.get(methodContext, expectedKey); + const userStoreData = await userStore.get( + methodContext, + userStore.getKey(address, expectedKey), + ); + expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); + expect(nftStoreData.owner).toStrictEqual(address); + expect(nftStoreData.attributesArray).toEqual(attributesArray3); + expect(userStoreData.lockingModule).toEqual(NFT_NOT_LOCKED); + checkEventResult(methodContext.eventQueue, 1, CreateEvent, 0, { + address, + nftID: expectedKey, + collectionID, + }); + }); + }); }); From d7425969d64f2755cd9aade70122d89da7664829 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 9 Jun 2023 11:07:31 +0200 Subject: [PATCH 19/58] Crosschain NFT Transfer (#8560) * :label: Updates InteroperabilityMethod * :seedling: NFT TransferCrossChainCommand * :recycle: :memo: NFTErrorEventResult :seedling: DestroyEvent.error * :label: Updates InteroperabilityMethod mock defintion * :recycle: Removes unwanted comments --- .../nft/commands/transfer_cross_chain.ts | 136 +++++ framework/src/modules/nft/constants.ts | 6 +- framework/src/modules/nft/events/destroy.ts | 15 +- framework/src/modules/nft/method.ts | 8 +- framework/src/modules/nft/schemas.ts | 61 ++- framework/src/modules/nft/types.ts | 3 +- .../nft/cc_comands/cc_transfer.spec.ts | 1 + .../nft/commands/transfer_cross_chain.spec.ts | 463 ++++++++++++++++++ .../unit/modules/nft/internal_method.spec.ts | 4 + .../test/unit/modules/nft/method.spec.ts | 1 + 10 files changed, 682 insertions(+), 16 deletions(-) create mode 100644 framework/src/modules/nft/commands/transfer_cross_chain.ts create mode 100644 framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts diff --git a/framework/src/modules/nft/commands/transfer_cross_chain.ts b/framework/src/modules/nft/commands/transfer_cross_chain.ts new file mode 100644 index 00000000000..97fd17a8826 --- /dev/null +++ b/framework/src/modules/nft/commands/transfer_cross_chain.ts @@ -0,0 +1,136 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { validator } from '@liskhq/lisk-validator'; +import { crossChainTransferParamsSchema } from '../schemas'; +import { NFTStore } from '../stores/nft'; +import { NFTMethod } from '../method'; +import { LENGTH_CHAIN_ID, NFT_NOT_LOCKED } from '../constants'; +import { TokenMethod } from '../../token'; +import { InteroperabilityMethod } from '../types'; +import { BaseCommand } from '../../base_command'; +import { + CommandExecuteContext, + CommandVerifyContext, + VerificationResult, + VerifyStatus, +} from '../../../state_machine'; +import { InternalMethod } from '../internal_method'; + +export interface Params { + nftID: Buffer; + receivingChainID: Buffer; + recipientAddress: Buffer; + data: string; + messageFee: bigint; + messageFeeTokenID: Buffer; + includeAttributes: boolean; +} + +export class TransferCrossChainCommand extends BaseCommand { + public schema = crossChainTransferParamsSchema; + + private _nftMethod!: NFTMethod; + private _tokenMethod!: TokenMethod; + private _interoperabilityMethod!: InteroperabilityMethod; + private _internalMethod!: InternalMethod; + + public init(args: { + nftMethod: NFTMethod; + tokenMethod: TokenMethod; + interoperabilityMethod: InteroperabilityMethod; + internalMethod: InternalMethod; + }): void { + this._nftMethod = args.nftMethod; + this._tokenMethod = args.tokenMethod; + this._interoperabilityMethod = args.interoperabilityMethod; + this._internalMethod = args.internalMethod; + } + + public async verify(context: CommandVerifyContext): Promise { + const { params } = context; + + validator.validate(this.schema, params); + + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(context.getMethodContext(), params.nftID); + + if (!nftExists) { + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this._nftMethod.getNFTOwner(context.getMethodContext(), params.nftID); + + if (owner.length === LENGTH_CHAIN_ID) { + throw new Error('NFT is escrowed to another chain'); + } + + const nftChainID = this._nftMethod.getChainID(params.nftID); + + if (!nftChainID.equals(context.chainID) && !nftChainID.equals(params.receivingChainID)) { + throw new Error('NFT must be native to either the sending or the receiving chain'); + } + + const messageFeeTokenID = await this._interoperabilityMethod.getMessageFeeTokenID( + context.getMethodContext(), + params.receivingChainID, + ); + + if (!params.messageFeeTokenID.equals(messageFeeTokenID)) { + throw new Error('Mismatching message fee Token ID'); + } + + if (!owner.equals(context.transaction.senderAddress)) { + throw new Error('Transfer not initiated by the NFT owner'); + } + + const lockingModule = await this._nftMethod.getLockingModule( + context.getMethodContext(), + params.nftID, + ); + + if (lockingModule !== NFT_NOT_LOCKED) { + throw new Error('Locked NFTs cannot be transferred'); + } + + const availableBalance = await this._tokenMethod.getAvailableBalance( + context.getMethodContext(), + context.transaction.senderAddress, + params.messageFeeTokenID, + ); + + if (availableBalance < params.messageFee) { + throw new Error('Insufficient balance for the message fee'); + } + + return { + status: VerifyStatus.OK, + }; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._internalMethod.transferCrossChainInternal( + context.getMethodContext(), + context.transaction.senderAddress, + params.recipientAddress, + params.nftID, + params.receivingChainID, + params.messageFee, + params.data, + params.includeAttributes, + ); + } +} diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index e732b5f17f2..e14f1ded273 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -25,6 +25,7 @@ export const CCM_STATUS_CODE_OK = 0; export const EMPTY_BYTES = Buffer.alloc(0); export const ALL_SUPPORTED_NFTS_KEY = EMPTY_BYTES; export const FEE_CREATE_NFT = 5000000; +export const LENGTH_TOKEN_ID = 8; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, @@ -43,7 +44,4 @@ export const enum NftEventResult { RESULT_DATA_TOO_LONG = 13, } -export type NftErrorEventResult = Exclude< - NftEventResult, - NftEventResult.RESULT_NFT_ESCROWED | NftEventResult.RESULT_SUCCESSFUL ->; +export type NftErrorEventResult = Exclude; diff --git a/framework/src/modules/nft/events/destroy.ts b/framework/src/modules/nft/events/destroy.ts index 3475c03a869..15bf0ffb7ad 100644 --- a/framework/src/modules/nft/events/destroy.ts +++ b/framework/src/modules/nft/events/destroy.ts @@ -13,7 +13,7 @@ */ import { BaseEvent, EventQueuer } from '../../base_event'; -import { LENGTH_NFT_ID, NftEventResult } from '../constants'; +import { LENGTH_NFT_ID, NftErrorEventResult, NftEventResult } from '../constants'; export interface DestroyEventData { address: Buffer; @@ -46,11 +46,14 @@ export const createEventSchema = { export class DestroyEvent extends BaseEvent { public schema = createEventSchema; - public log( - ctx: EventQueuer, - data: DestroyEventData, - result: NftEventResult = NftEventResult.RESULT_SUCCESSFUL, - ): void { + public log(ctx: EventQueuer, data: DestroyEventData): void { + this.add(ctx, { ...data, result: NftEventResult.RESULT_SUCCESSFUL }, [ + data.address, + data.nftID, + ]); + } + + public error(ctx: EventQueuer, data: DestroyEventData, result: NftErrorEventResult): void { this.add(ctx, { ...data, result }, [data.address, data.nftID]); } } diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 83c2593982a..0d15d1f0305 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -92,7 +92,7 @@ export class NFTMethod extends BaseMethod { const nftExists = await nftStore.has(methodContext, nftID); if (!nftExists) { - this.events.get(DestroyEvent).log( + this.events.get(DestroyEvent).error( methodContext, { address, @@ -107,7 +107,7 @@ export class NFTMethod extends BaseMethod { const owner = await this.getNFTOwner(methodContext, nftID); if (owner.length === LENGTH_CHAIN_ID) { - this.events.get(DestroyEvent).log( + this.events.get(DestroyEvent).error( methodContext, { address, @@ -120,7 +120,7 @@ export class NFTMethod extends BaseMethod { } if (!owner.equals(address)) { - this.events.get(DestroyEvent).log( + this.events.get(DestroyEvent).error( methodContext, { address, @@ -137,7 +137,7 @@ export class NFTMethod extends BaseMethod { const { lockingModule } = await userStore.get(methodContext, userKey); if (lockingModule !== NFT_NOT_LOCKED) { - this.events.get(DestroyEvent).log( + this.events.get(DestroyEvent).error( methodContext, { address, diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 4bccf862491..9b261363e18 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -13,7 +13,13 @@ */ import { MAX_DATA_LENGTH } from '../token/constants'; -import { LENGTH_NFT_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from './constants'; +import { + LENGTH_CHAIN_ID, + LENGTH_NFT_ID, + LENGTH_TOKEN_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, +} from './constants'; export const transferParamsSchema = { $id: '/lisk/nftTransferParams', @@ -97,3 +103,56 @@ export interface CCTransferMessageParams { recipientAddress: Buffer; data: string; } + +export const crossChainTransferParamsSchema = { + $id: '/lisk/crossChainNFTTransferParamsSchema', + type: 'object', + required: [ + 'nftID', + 'receivingChainID', + 'recipientAddress', + 'data', + 'messageFee', + 'messageFeeTokenID', + 'includeAttributes', + ], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + receivingChainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 2, + }, + recipientAddress: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 3, + }, + data: { + dataType: 'string', + minLength: 0, + maxLength: MAX_DATA_LENGTH, + fieldNumber: 4, + }, + messageFee: { + dataType: 'uint64', + fieldNumber: 5, + }, + messageFeeTokenID: { + dataType: 'bytes', + minLength: LENGTH_TOKEN_ID, + maxLength: LENGTH_TOKEN_ID, + fieldNumber: 6, + }, + includeAttributes: { + dataType: 'boolean', + fieldNumber: 7, + }, + }, +}; diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 74d123c56aa..d71c76e83a7 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -12,7 +12,7 @@ * Removal or modification of this copyright notice is prohibited. */ -import { MethodContext } from '../../state_machine'; +import { ImmutableMethodContext, MethodContext } from '../../state_machine'; import { CCMsg } from '../interoperability'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -34,6 +34,7 @@ export interface InteroperabilityMethod { ): Promise; error(methodContext: MethodContext, ccm: CCMsg, code: number): Promise; terminateChain(methodContext: MethodContext, chainID: Buffer): Promise; + getMessageFeeTokenID(methodContext: ImmutableMethodContext, chainID: Buffer): Promise; } export interface FeeMethod { diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index 0e09de2bf26..c84cbee033d 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -87,6 +87,7 @@ describe('CrossChain Transfer Command', () => { send: jest.fn(), error: jest.fn(), terminateChain: jest.fn(), + getMessageFeeTokenID: jest.fn(), }; const defaultHeader = { height: 0, diff --git a/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts new file mode 100644 index 00000000000..ba942e60893 --- /dev/null +++ b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts @@ -0,0 +1,463 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { Transaction } from '@liskhq/lisk-chain'; +import { codec } from '@liskhq/lisk-codec'; +import { address, utils } from '@liskhq/lisk-cryptography'; +import { NFTModule } from '../../../../../src/modules/nft/module'; +import { + TransferCrossChainCommand, + Params, +} from '../../../../../src/modules/nft/commands/transfer_cross_chain'; +import { crossChainTransferParamsSchema } from '../../../../../src/modules/nft/schemas'; +import { + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_NFT_ID, + LENGTH_TOKEN_ID, + NFT_NOT_LOCKED, +} from '../../../../../src/modules/nft/constants'; +import { InMemoryPrefixedStateDB } from '../../../../../src/testing/in_memory_prefixed_state'; +import { PrefixedStateReadWriter } from '../../../../../src/state_machine/prefixed_state_read_writer'; +import { EventQueue, VerifyStatus, createMethodContext } from '../../../../../src/state_machine'; +import { TokenMethod } from '../../../../../src'; +import { MethodContext } from '../../../../../src/state_machine/method_context'; +import { NFTStore } from '../../../../../src/modules/nft/stores/nft'; +import { UserStore } from '../../../../../src/modules/nft/stores/user'; +import * as Token from '../../../../../src/modules/token/stores/user'; +import { NFTMethod } from '../../../../../src/modules/nft/method'; +import { InteroperabilityMethod } from '../../../../../src/modules/nft/types'; +import { createTransactionContext } from '../../../../../src/testing'; +import { InternalMethod } from '../../../../../src/modules/nft/internal_method'; +import { + TransferCrossChainEvent, + TransferCrossChainEventData, +} from '../../../../../src/modules/nft/events/transfer_cross_chain'; + +describe('TransferCrossChainComand', () => { + const module = new NFTModule(); + module.stores.register( + Token.UserStore, + new Token.UserStore(module.name, module.stores.keys.length + 1), + ); + + const command = new TransferCrossChainCommand(module.stores, module.events); + const nftMethod = new NFTMethod(module.stores, module.events); + const tokenMethod = new TokenMethod(module.stores, module.events, module.name); + const internalMethod = new InternalMethod(module.stores, module.events); + let interoperabilityMethod!: InteroperabilityMethod; + + const senderPublicKey = utils.getRandomBytes(32); + const owner = address.getAddressFromPublicKey(senderPublicKey); + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const messageFeeTokenID = utils.getRandomBytes(LENGTH_TOKEN_ID); + const availableBalance = BigInt(1000000); + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + const tokenUserStore = module.stores.get(Token.UserStore); + + let stateStore!: PrefixedStateReadWriter; + let methodContext!: MethodContext; + + let existingNFT: { nftID: any; owner: any }; + let lockedExistingNFT: { nftID: any; owner: any }; + let escrowedNFT: { nftID: any; owner: any }; + + const validParams: Params = { + nftID: Buffer.alloc(LENGTH_NFT_ID), + receivingChainID, + recipientAddress: utils.getRandomBytes(LENGTH_ADDRESS), + data: '', + messageFee: BigInt(100000), + messageFeeTokenID, + includeAttributes: false, + }; + + const checkEventResult = ( + eventQueue: EventQueue, + length: number, + EventClass: any, + index: number, + expectedResult: EventDataType, + result: any = 0, + ) => { + expect(eventQueue.getEvents()).toHaveLength(length); + expect(eventQueue.getEvents()[index].toObject().name).toEqual(new EventClass('nft').name); + + const eventData = codec.decode>( + new EventClass('nft').schema, + eventQueue.getEvents()[index].toObject().data, + ); + + expect(eventData).toEqual({ ...expectedResult, result }); + }; + + const createTransactionContextWithOverridingParams = ( + params: Record, + txParams: Record = {}, + ) => + createTransactionContext({ + chainID: ownChainID, + stateStore, + transaction: new Transaction({ + module: module.name, + command: 'transfer', + fee: BigInt(5000000), + nonce: BigInt(0), + senderPublicKey, + params: codec.encode(crossChainTransferParamsSchema, { + ...validParams, + ...params, + }), + signatures: [utils.getRandomBytes(64)], + ...txParams, + }), + }); + + beforeEach(async () => { + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + + methodContext = createMethodContext({ + stateStore, + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + + interoperabilityMethod = { + send: jest.fn().mockResolvedValue(Promise.resolve()), + error: jest.fn().mockResolvedValue(Promise.resolve()), + terminateChain: jest.fn().mockResolvedValue(Promise.resolve()), + getMessageFeeTokenID: jest.fn().mockResolvedValue(Promise.resolve(messageFeeTokenID)), + }; + + internalMethod.init({ + ownChainID, + }); + + internalMethod.addDependencies(nftMethod, interoperabilityMethod); + + command.init({ nftMethod, tokenMethod, interoperabilityMethod, internalMethod }); + + existingNFT = { + owner, + nftID: Buffer.concat([ownChainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]), + }; + + lockedExistingNFT = { + owner, + nftID: Buffer.concat([ownChainID, utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]), + }; + + escrowedNFT = { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + await nftStore.save(methodContext, existingNFT.nftID, { + owner: existingNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await module.stores.get(NFTStore).save(methodContext, lockedExistingNFT.nftID, { + owner: lockedExistingNFT.owner, + attributesArray: [], + }); + + await userStore.set( + methodContext, + userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), + { + lockingModule: 'token', + }, + ); + + await module.stores.get(NFTStore).save(methodContext, escrowedNFT.nftID, { + owner: escrowedNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(escrowedNFT.owner, escrowedNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await tokenUserStore.set(methodContext, tokenUserStore.getKey(owner, messageFeeTokenID), { + availableBalance, + lockedBalances: [], + }); + }); + + describe('verify', () => { + it('should fail if NFT does not have valid length', async () => { + const nftMinLengthContext = createTransactionContextWithOverridingParams({ + nftID: utils.getRandomBytes(LENGTH_NFT_ID - 1), + }); + + const nftMaxLengthContext = createTransactionContextWithOverridingParams({ + nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), + }); + + await expect( + command.verify( + nftMinLengthContext.createCommandVerifyContext(crossChainTransferParamsSchema), + ), + ).rejects.toThrow("'.nftID' minLength not satisfied"); + + await expect( + command.verify( + nftMaxLengthContext.createCommandExecuteContext(crossChainTransferParamsSchema), + ), + ).rejects.toThrow("'.nftID' maxLength exceeded"); + }); + + it('should fail if receivingChainID does not have valid length', async () => { + const receivingChainIDMinLengthContext = createTransactionContextWithOverridingParams({ + receivingChainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), + }); + + const receivingChainIDMaxLengthContext = createTransactionContextWithOverridingParams({ + receivingChainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1), + }); + + await expect( + command.verify( + receivingChainIDMinLengthContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.receivingChainID' minLength not satisfied"); + + await expect( + command.verify( + receivingChainIDMaxLengthContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.receivingChainID' maxLength exceeded"); + }); + + it('should fail if recipientAddress does not have valid length', async () => { + const recipientAddressMinLengthContext = createTransactionContextWithOverridingParams({ + recipientAddress: utils.getRandomBytes(LENGTH_ADDRESS - 1), + }); + + const recipientAddressMaxLenghtContext = createTransactionContextWithOverridingParams({ + recipientAddress: utils.getRandomBytes(LENGTH_ADDRESS + 1), + }); + + await expect( + command.verify( + recipientAddressMinLengthContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.recipientAddress' address length invalid"); + + await expect( + command.verify( + recipientAddressMaxLenghtContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.recipientAddress' address length invalid"); + }); + + it('should fail if data has more than 64 characters', async () => { + const dataMaxLengthContext = createTransactionContextWithOverridingParams({ + data: '1'.repeat(65), + }); + + await expect( + command.verify( + dataMaxLengthContext.createCommandVerifyContext(crossChainTransferParamsSchema), + ), + ).rejects.toThrow("'.data' must NOT have more than 64 characters"); + }); + + it('should fail if messageFeeTokenID does not have valid length', async () => { + const messageFeeTokenIDMinLengthContext = createTransactionContextWithOverridingParams({ + messageFeeTokenID: utils.getRandomBytes(LENGTH_TOKEN_ID - 1), + }); + + const messageFeeTokenIDMaxLengthContext = createTransactionContextWithOverridingParams({ + messageFeeTokenID: utils.getRandomBytes(LENGTH_TOKEN_ID + 1), + }); + + await expect( + command.verify( + messageFeeTokenIDMinLengthContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.messageFeeTokenID' minLength not satisfied"); + + await expect( + command.verify( + messageFeeTokenIDMaxLengthContext.createCommandVerifyContext( + crossChainTransferParamsSchema, + ), + ), + ).rejects.toThrow("'.messageFeeTokenID' maxLength exceeded"); + }); + + it('should fail if NFT does not exist', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('NFT substore entry does not exist'); + }); + + it('should fail if NFT is escrowed', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: escrowedNFT.nftID, + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('NFT is escrowed to another chain'); + }); + + it('should fail if NFT is not native to either the sending or receiving chain', async () => { + const nftID = utils.getRandomBytes(LENGTH_ADDRESS); + + const context = createTransactionContextWithOverridingParams({ + nftID, + }); + + await nftStore.save(methodContext, nftID, { + owner, + attributesArray: [], + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow(''); + }); + + it('should fail if messageFeeTokenID for receiving chain differs from the messageFeeTokenID of parameters', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: existingNFT.nftID, + messageFeeTokenID: utils.getRandomBytes(LENGTH_TOKEN_ID), + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('Mismatching message fee Token ID'); + }); + + it('should fail if the owner of the NFT is not the sender', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: existingNFT.nftID, + }); + + const nft = await nftStore.get(methodContext, existingNFT.nftID); + nft.owner = utils.getRandomBytes(LENGTH_ADDRESS); + await nftStore.save(methodContext, existingNFT.nftID, nft); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + }); + + it('should fail if NFT is locked', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: lockedExistingNFT.nftID, + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + }); + + it('should fail if senders has insufficient balance of value messageFee and token messageFeeTokenID', async () => { + const context = createTransactionContextWithOverridingParams({ + messageFeeTokenID, + messageFee: availableBalance + BigInt(1), + nftID: existingNFT.nftID, + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).rejects.toThrow('Insufficient balance for the message fee'); + }); + + it('should verify if NFT is native', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: existingNFT.nftID, + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).resolves.toEqual({ status: VerifyStatus.OK }); + }); + + it('should verify if NFT is native to receiving chain', async () => { + const nftID = Buffer.concat([ + receivingChainID, + utils.getRandomBytes(LENGTH_NFT_ID - LENGTH_CHAIN_ID), + ]); + + await nftStore.save(methodContext, nftID, { + owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(owner, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + const context = createTransactionContextWithOverridingParams({ + nftID, + }); + + await expect( + command.verify(context.createCommandVerifyContext(crossChainTransferParamsSchema)), + ).resolves.toEqual({ status: VerifyStatus.OK }); + }); + }); + + describe('execute', () => { + it('should transfer NFT and emit TransferCrossChainEvent', async () => { + const context = createTransactionContextWithOverridingParams({ + nftID: existingNFT.nftID, + }); + + await expect( + command.execute(context.createCommandExecuteContext(crossChainTransferParamsSchema)), + ).resolves.toBeUndefined(); + + checkEventResult( + context.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: owner, + recipientAddress: validParams.recipientAddress, + receivingChainID: validParams.receivingChainID, + nftID: existingNFT.nftID, + includeAttributes: validParams.includeAttributes, + }, + ); + }); + }); +}); diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 270a29d3786..31dcd1a99c4 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -26,6 +26,7 @@ import { LENGTH_NFT_ID, MODULE_NAME_NFT, NFT_NOT_LOCKED, + LENGTH_TOKEN_ID, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { MethodContext } from '../../../../src/state_machine/method_context'; @@ -195,6 +196,9 @@ describe('InternalMethod', () => { send: jest.fn().mockResolvedValue(Promise.resolve()), error: jest.fn().mockResolvedValue(Promise.resolve()), terminateChain: jest.fn().mockRejectedValue(Promise.resolve()), + getMessageFeeTokenID: jest + .fn() + .mockResolvedValue(Promise.resolve(utils.getRandomBytes(LENGTH_TOKEN_ID))), }; internalMethod.addDependencies(method, interoperabilityMethod); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index cea60f266f4..d43fba2e223 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -519,6 +519,7 @@ describe('NFTMethod', () => { send: jest.fn(), error: jest.fn(), terminateChain: jest.fn(), + getMessageFeeTokenID: jest.fn(), }; const feeMethod = { payFee: jest.fn() }; const attributesArray1 = [ From be7e6fbe297a04c27dc199a140df04dc35a67916 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 9 Jun 2023 16:43:21 +0200 Subject: [PATCH 20/58] Lock and Unlock method for NFT (#8561) * :label: Updates NftErrorEventResult * :seedling: LockEvent.error * :seedling: NFTMethod.lock & NFTMethod.unlock * :bug: Fixes NFTMethod.unlock * :bug: Fixes NFTMethod.unlock to not log event if NFT is escrowed --- framework/src/modules/nft/events/lock.ts | 5 + framework/src/modules/nft/method.ts | 125 ++++++++ .../test/unit/modules/nft/method.spec.ts | 275 ++++++++++++++---- 3 files changed, 352 insertions(+), 53 deletions(-) diff --git a/framework/src/modules/nft/events/lock.ts b/framework/src/modules/nft/events/lock.ts index 9820836158f..b52ba2de613 100644 --- a/framework/src/modules/nft/events/lock.ts +++ b/framework/src/modules/nft/events/lock.ts @@ -17,6 +17,7 @@ import { LENGTH_NFT_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME, + NftErrorEventResult, NftEventResult, } from '../constants'; @@ -58,4 +59,8 @@ export class LockEvent extends BaseEvent { + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(methodContext, nftID); + + if (!nftExists) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + + throw new Error('NFT is escrowed to another chain'); + } + + const userStore = this.stores.get(UserStore); + const userKey = userStore.getKey(owner, nftID); + const userData = await userStore.get(methodContext, userKey); + + if (userData.lockingModule !== NFT_NOT_LOCKED) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + + throw new Error('NFT is already locked'); + } + + userData.lockingModule = module; + + await userStore.set(methodContext, userKey, userData); + + this.events.get(LockEvent).log(methodContext, { + module, + nftID, + }); + } + + public async unlock(methodContext: MethodContext, module: string, nftID: Buffer): Promise { + const nftStore = this.stores.get(NFTStore); + + const nftExists = await nftStore.has(methodContext, nftID); + + if (!nftExists) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + + throw new Error('NFT substore entry does not exist'); + } + + const nftData = await nftStore.get(methodContext, nftID); + + if (nftData.owner.length === LENGTH_CHAIN_ID) { + throw new Error('NFT is escrowed to another chain'); + } + + const userStore = this.stores.get(UserStore); + const userKey = userStore.getKey(nftData.owner, nftID); + const userData = await userStore.get(methodContext, userKey); + + if (userData.lockingModule === NFT_NOT_LOCKED) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_NFT_NOT_LOCKED, + ); + + throw new Error('NFT is not locked'); + } + + if (userData.lockingModule !== module) { + this.events.get(LockEvent).error( + methodContext, + { + module, + nftID, + }, + NftEventResult.RESULT_UNAUTHORIZED_UNLOCK, + ); + + throw new Error('Unlocking NFT via module that did not lock it'); + } + + userData.lockingModule = NFT_NOT_LOCKED; + + await userStore.set(methodContext, userKey, userData); + + this.events.get(LockEvent).log(methodContext, { + module, + nftID, + }); + } } diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index d43fba2e223..1f14775963f 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -35,6 +35,7 @@ import { UserStore } from '../../../../src/modules/nft/stores/user'; import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; import { CreateEvent } from '../../../../src/modules/nft/events/create'; +import { LockEvent, LockEventData } from '../../../../src/modules/nft/events/lock'; describe('NFTMethod', () => { const module = new NFTModule(); @@ -67,7 +68,11 @@ describe('NFTMethod', () => { expect(eventData).toEqual({ ...expectedResult, result }); }; - beforeEach(() => { + let existingNFT: { nftID: any; owner: any }; + let lockedExistingNFT: { nftID: any; owner: any; lockingModule: string }; + let escrowedNFT: { nftID: any; owner: any }; + + beforeEach(async () => { owner = utils.getRandomBytes(LENGTH_ADDRESS); methodContext = createMethodContext({ @@ -75,6 +80,53 @@ describe('NFTMethod', () => { eventQueue: new EventQueue(0), contextStore: new Map(), }); + + existingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + lockedExistingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + lockingModule: 'token', + }; + + escrowedNFT = { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + await nftStore.save(methodContext, existingNFT.nftID, { + owner: existingNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + + await nftStore.save(methodContext, lockedExistingNFT.nftID, { + owner: lockedExistingNFT.owner, + attributesArray: [], + }); + + await userStore.set( + methodContext, + userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), + { + lockingModule: lockedExistingNFT.lockingModule, + }, + ); + + await nftStore.save(methodContext, escrowedNFT.nftID, { + owner: escrowedNFT.owner, + attributesArray: [], + }); + + await userStore.set(methodContext, userStore.getKey(escrowedNFT.owner, escrowedNFT.nftID), { + lockingModule: NFT_NOT_LOCKED, + }); }); describe('getChainID', () => { @@ -143,58 +195,6 @@ describe('NFTMethod', () => { }); describe('destroy', () => { - let existingNFT: { nftID: any; owner: any }; - let lockedExistingNFT: { nftID: any; owner: any }; - let escrowedNFT: { nftID: any; owner: any }; - - beforeEach(async () => { - existingNFT = { - owner: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - }; - - lockedExistingNFT = { - owner: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - }; - - escrowedNFT = { - owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - }; - - await nftStore.save(methodContext, existingNFT.nftID, { - owner: existingNFT.owner, - attributesArray: [], - }); - - await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { - lockingModule: NFT_NOT_LOCKED, - }); - - await nftStore.save(methodContext, lockedExistingNFT.nftID, { - owner: lockedExistingNFT.owner, - attributesArray: [], - }); - - await userStore.set( - methodContext, - userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), - { - lockingModule: 'token', - }, - ); - - await nftStore.save(methodContext, escrowedNFT.nftID, { - owner: escrowedNFT.owner, - attributesArray: [], - }); - - await userStore.set(methodContext, userStore.getKey(escrowedNFT.owner, escrowedNFT.nftID), { - lockingModule: NFT_NOT_LOCKED, - }); - }); - it('should fail and emit Destroy event if NFT does not exist', async () => { const address = utils.getRandomBytes(LENGTH_ADDRESS); @@ -591,4 +591,173 @@ describe('NFTMethod', () => { }); }); }); + + describe('lock', () => { + it('should throw and log LockEvent if NFT does not exist', async () => { + await expect(method.lock(methodContext, module.name, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw and log LockEvent if NFT is escrowed', async () => { + await expect(method.lock(methodContext, module.name, escrowedNFT.nftID)).rejects.toThrow( + 'NFT is escrowed to another chain', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID: escrowedNFT.nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should throw and log LockEvent if NFT is locked', async () => { + await expect( + method.lock(methodContext, module.name, lockedExistingNFT.nftID), + ).rejects.toThrow('NFT is already locked'); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should update the locking module and log LockEvent', async () => { + const expectedLockingModule = 'lockingModule'; + await expect( + method.lock(methodContext, expectedLockingModule, existingNFT.nftID), + ).resolves.toBeUndefined(); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: expectedLockingModule, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_SUCCESSFUL, + ); + + const { lockingModule } = await userStore.get( + methodContext, + userStore.getKey(existingNFT.owner, existingNFT.nftID), + ); + + expect(lockingModule).toEqual(expectedLockingModule); + }); + }); + + describe('unlock', () => { + it('should throw and log LockEvent if NFT does not exist', async () => { + await expect(method.unlock(methodContext, module.name, nftID)).rejects.toThrow( + 'NFT substore entry does not exist', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw if NFT is escrowed', async () => { + await expect(method.unlock(methodContext, module.name, escrowedNFT.nftID)).rejects.toThrow( + 'NFT is escrowed to another chain', + ); + }); + + it('should throw and log LockEvent if NFT is not locked', async () => { + await expect(method.unlock(methodContext, module.name, existingNFT.nftID)).rejects.toThrow( + 'NFT is not locked', + ); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_NFT_NOT_LOCKED, + ); + }); + + it('should throw and log LockEvent if unlocking module is not the locking module', async () => { + await expect( + method.unlock(methodContext, module.name, lockedExistingNFT.nftID), + ).rejects.toThrow('Unlocking NFT via module that did not lock it'); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: module.name, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_UNAUTHORIZED_UNLOCK, + ); + }); + + it('should unlock and log LockEvent', async () => { + await expect( + method.unlock(methodContext, lockedExistingNFT.lockingModule, lockedExistingNFT.nftID), + ).resolves.toBeUndefined(); + + checkEventResult( + methodContext.eventQueue, + 1, + LockEvent, + 0, + { + module: lockedExistingNFT.lockingModule, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_SUCCESSFUL, + ); + + const { lockingModule } = await userStore.get( + methodContext, + userStore.getKey(lockedExistingNFT.owner, lockedExistingNFT.nftID), + ); + + expect(lockingModule).toEqual(NFT_NOT_LOCKED); + }); + }); }); From 13bde5c7677bb0be861e860a7db65b5dbb09f7ff Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Tue, 13 Jun 2023 16:29:42 +0200 Subject: [PATCH 21/58] SupportNFTs Methods (#8577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: :white_check_mark: setup code for NFTMethods * :seedling: SupportedNFTsStore.getAll * Updates EventQueue.add to allow empty topics * :seedling: Adds NFTMethod.supportAllNFTs & NFTMethod.removeSupportAllNFTs * :seedling: Adds NFTMethod.supportAllNFTsFromChain & NFTMethod.removeSupportAllNFTsFromChain * :seedling: Adds NFTMethod.supportAllNFTsFromCollection & NFTMethod.removeSupportAllNFTsFromCollection * :pencil2: * :bug: Fixes NFTMethod.removeSupportAllNFTsFromChain to throw AllNFTsFromChainSupportRemovedEvent * :bug: Fixes NFTMethod.supportAllNFTs to return if all are already supported * :recycle: Updates test description * :white_check_mark: Updates tests for NFTMethod.supportAllNFTsFromCollection * :recycle: Updates NFTMethod.removeSupportAllNFTsFromCollection * Updates test description. Co-authored-by: Miroslav Jerković Updates test description. Co-authored-by: Miroslav Jerković Updates test description. Co-authored-by: Miroslav Jerković * :art: --- framework/src/modules/nft/method.ts | 212 +++++++ framework/src/modules/nft/module.ts | 5 + .../src/modules/nft/stores/supported_nfts.ts | 13 +- framework/src/state_machine/event_queue.ts | 4 +- .../test/unit/modules/nft/method.spec.ts | 540 ++++++++++++++++-- .../modules/nft/stores/supported_nfts.spec.ts | 23 +- .../unit/state_machine/event_queue.spec.ts | 11 - 7 files changed, 741 insertions(+), 67 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index fe20b86cef4..231032309c1 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -29,6 +29,12 @@ import { DestroyEvent } from './events/destroy'; import { SupportedNFTsStore } from './stores/supported_nfts'; import { CreateEvent } from './events/create'; import { LockEvent } from './events/lock'; +import { AllNFTsSupportedEvent } from './events/all_nfts_supported'; +import { AllNFTsSupportRemovedEvent } from './events/all_nfts_support_removed'; +import { AllNFTsFromChainSupportedEvent } from './events/all_nfts_from_chain_suported'; +import { AllNFTsFromCollectionSupportedEvent } from './events/all_nfts_from_collection_suppported'; +import { AllNFTsFromCollectionSupportRemovedEvent } from './events/all_nfts_from_collection_support_removed'; +import { AllNFTsFromChainSupportRemovedEvent } from './events/all_nfts_from_chain_support_removed'; export class NFTMethod extends BaseMethod { private _config!: ModuleConfig; @@ -420,4 +426,210 @@ export class NFTMethod extends BaseMethod { nftID, }); } + + public async supportAllNFTs(methodContext: MethodContext): Promise { + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const alreadySupported = await supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY); + + if (alreadySupported) { + return; + } + + const allSupportedNFTs = await supportedNFTsStore.getAll(methodContext); + + for (const { key } of allSupportedNFTs) { + await supportedNFTsStore.del(methodContext, key); + } + + await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + this.events.get(AllNFTsSupportedEvent).log(methodContext); + } + + public async removeSupportAllNFTs(methodContext: MethodContext): Promise { + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const allSupportedNFTs = await supportedNFTsStore.getAll(methodContext); + + for (const { key } of allSupportedNFTs) { + await supportedNFTsStore.del(methodContext, key); + } + + this.events.get(AllNFTsSupportRemovedEvent).log(methodContext); + } + + public async supportAllNFTsFromChain( + methodContext: MethodContext, + chainID: Buffer, + ): Promise { + if (chainID.equals(this._config.ownChainID)) { + return; + } + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + const allNFTsSuppported = await supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY); + + if (allNFTsSuppported) { + return; + } + + const chainSupportExists = await supportedNFTsStore.has(methodContext, chainID); + + if (chainSupportExists) { + const supportedCollections = await supportedNFTsStore.get(methodContext, chainID); + + if (supportedCollections.supportedCollectionIDArray.length === 0) { + return; + } + } + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + this.events.get(AllNFTsFromChainSupportedEvent).log(methodContext, chainID); + } + + public async removeSupportAllNFTsFromChain( + methodContext: MethodContext, + chainID: Buffer, + ): Promise { + if (chainID.equals(this._config.ownChainID)) { + throw new Error('Support for native NFTs cannot be removed'); + } + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const allNFTsSupported = await supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY); + + if (allNFTsSupported) { + throw new Error('All NFTs from all chains are supported'); + } + + const isChainSupported = await supportedNFTsStore.has(methodContext, chainID); + + if (!isChainSupported) { + return; + } + + await supportedNFTsStore.del(methodContext, chainID); + + this.events.get(AllNFTsFromChainSupportRemovedEvent).log(methodContext, chainID); + } + + public async supportAllNFTsFromCollection( + methodContext: MethodContext, + chainID: Buffer, + collectionID: Buffer, + ): Promise { + if (chainID.equals(this._config.ownChainID)) { + return; + } + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + const allNFTsSupported = await supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY); + + if (allNFTsSupported) { + return; + } + + const isChainSupported = await supportedNFTsStore.has(methodContext, chainID); + + let supportedChainData; + if (isChainSupported) { + supportedChainData = await supportedNFTsStore.get(methodContext, chainID); + + if (supportedChainData.supportedCollectionIDArray.length === 0) { + return; + } + + if ( + supportedChainData.supportedCollectionIDArray.some(collection => + collection.collectionID.equals(collectionID), + ) + ) { + return; + } + + supportedChainData.supportedCollectionIDArray.push({ collectionID }); + + await supportedNFTsStore.save(methodContext, chainID, supportedChainData); + + this.events.get(AllNFTsFromCollectionSupportedEvent).log(methodContext, { + chainID, + collectionID, + }); + + return; + } + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID, + }, + ], + }); + + this.events.get(AllNFTsFromCollectionSupportedEvent).log(methodContext, { + chainID, + collectionID, + }); + } + + public async removeSupportAllNFTsFromCollection( + methodContext: MethodContext, + chainID: Buffer, + collectionID: Buffer, + ): Promise { + if (chainID.equals(this._config.ownChainID)) { + return; + } + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const allNFTsSupported = await supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY); + + if (allNFTsSupported) { + throw new Error('All NFTs from all chains are supported'); + } + + const isChainSupported = await supportedNFTsStore.has(methodContext, chainID); + + if (!isChainSupported) { + return; + } + const supportedChainData = await supportedNFTsStore.get(methodContext, chainID); + + if (supportedChainData.supportedCollectionIDArray.length === 0) { + throw new Error('All NFTs from the specified chain are supported'); + } + + if ( + supportedChainData.supportedCollectionIDArray.some(supportedCollection => + supportedCollection.collectionID.equals(collectionID), + ) + ) { + supportedChainData.supportedCollectionIDArray = + supportedChainData.supportedCollectionIDArray.filter( + supportedCollection => !supportedCollection.collectionID.equals(collectionID), + ); + } + + if (supportedChainData.supportedCollectionIDArray.length === 0) { + await supportedNFTsStore.del(methodContext, chainID); + } else { + await supportedNFTsStore.save(methodContext, chainID, { + ...supportedChainData, + }); + } + + this.events.get(AllNFTsFromCollectionSupportRemovedEvent).log(methodContext, { + chainID, + collectionID, + }); + } } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 5518b54092d..8d0811f23ec 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -19,6 +19,7 @@ import { InteroperabilityMethod } from '../token/types'; import { NFTInteroperableMethod } from './cc_method'; import { NFTEndpoint } from './endpoint'; import { AllNFTsFromChainSupportedEvent } from './events/all_nfts_from_chain_suported'; +import { AllNFTsFromChainSupportRemovedEvent } from './events/all_nfts_from_chain_support_removed'; import { AllNFTsFromCollectionSupportRemovedEvent } from './events/all_nfts_from_collection_support_removed'; import { AllNFTsFromCollectionSupportedEvent } from './events/all_nfts_from_collection_suppported'; import { AllNFTsSupportRemovedEvent } from './events/all_nfts_support_removed'; @@ -70,6 +71,10 @@ export class NFTModule extends BaseInteroperableModule { AllNFTsFromChainSupportedEvent, new AllNFTsFromChainSupportedEvent(this.name), ); + this.events.register( + AllNFTsFromChainSupportRemovedEvent, + new AllNFTsFromChainSupportRemovedEvent(this.name), + ); this.events.register( AllNFTsFromCollectionSupportedEvent, new AllNFTsFromCollectionSupportedEvent(this.name), diff --git a/framework/src/modules/nft/stores/supported_nfts.ts b/framework/src/modules/nft/stores/supported_nfts.ts index e16dcb0838e..63668534d31 100644 --- a/framework/src/modules/nft/stores/supported_nfts.ts +++ b/framework/src/modules/nft/stores/supported_nfts.ts @@ -12,8 +12,8 @@ * Removal or modification of this copyright notice is prohibited. */ -import { BaseStore, StoreGetter } from '../../base_store'; -import { LENGTH_COLLECTION_ID } from '../constants'; +import { BaseStore, ImmutableStoreGetter, StoreGetter } from '../../base_store'; +import { LENGTH_COLLECTION_ID, LENGTH_CHAIN_ID } from '../constants'; export interface SupportedNFTsStoreData { supportedCollectionIDArray: { @@ -59,4 +59,13 @@ export class SupportedNFTsStore extends BaseStore { await this.set(context, chainID, { supportedCollectionIDArray }); } + + public async getAll( + context: ImmutableStoreGetter, + ): Promise<{ key: Buffer; value: SupportedNFTsStoreData }[]> { + return this.iterate(context, { + gte: Buffer.alloc(LENGTH_CHAIN_ID, 0), + lte: Buffer.alloc(LENGTH_CHAIN_ID, 255), + }); + } } diff --git a/framework/src/state_machine/event_queue.ts b/framework/src/state_machine/event_queue.ts index 25954575558..a498fb07576 100644 --- a/framework/src/state_machine/event_queue.ts +++ b/framework/src/state_machine/event_queue.ts @@ -43,9 +43,7 @@ export class EventQueue { `Max size of event data is ${EVENT_MAX_EVENT_SIZE_BYTES} but received ${data.length}`, ); } - if (!allTopics.length) { - throw new Error('Topics must have at least one element.'); - } + if (allTopics.length > EVENT_MAX_TOPICS_PER_EVENT) { throw new Error( `Max topics per event is ${EVENT_MAX_TOPICS_PER_EVENT} but received ${allTopics.length}`, diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 1f14775963f..f80796d12d5 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -36,15 +36,36 @@ import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/even import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; import { CreateEvent } from '../../../../src/modules/nft/events/create'; import { LockEvent, LockEventData } from '../../../../src/modules/nft/events/lock'; +import { AllNFTsSupportedEvent } from '../../../../src/modules/nft/events/all_nfts_supported'; +import { AllNFTsSupportRemovedEvent } from '../../../../src/modules/nft/events/all_nfts_support_removed'; +import { + AllNFTsFromChainSupportedEvent, + AllNFTsFromChainSupportedEventData, +} from '../../../../src/modules/nft/events/all_nfts_from_chain_suported'; +import { + AllNFTsFromCollectionSupportRemovedEvent, + AllNFTsFromCollectionSupportRemovedEventData, +} from '../../../../src/modules/nft/events/all_nfts_from_collection_support_removed'; +import { + AllNFTsFromCollectionSupportedEvent, + AllNFTsFromCollectionSupportedEventData, +} from '../../../../src/modules/nft/events/all_nfts_from_collection_suppported'; +import { + AllNFTsFromChainSupportRemovedEvent, + AllNFTsFromChainSupportRemovedEventData, +} from '../../../../src/modules/nft/events/all_nfts_from_chain_support_removed'; describe('NFTMethod', () => { const module = new NFTModule(); const method = new NFTMethod(module.stores, module.events); + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + method.init({ ownChainID }); let methodContext!: MethodContext; const nftStore = module.stores.get(NFTStore); const userStore = module.stores.get(UserStore); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); const nftID = utils.getRandomBytes(LENGTH_NFT_ID); let owner: Buffer; @@ -65,10 +86,13 @@ describe('NFTMethod', () => { eventQueue.getEvents()[index].toObject().data, ); - expect(eventData).toEqual({ ...expectedResult, result }); + if (result !== null) { + expect(eventData).toEqual({ ...expectedResult, result }); + } }; let existingNFT: { nftID: any; owner: any }; + let existingNativeNFT: { nftID: any; owner: any }; let lockedExistingNFT: { nftID: any; owner: any; lockingModule: string }; let escrowedNFT: { nftID: any; owner: any }; @@ -86,6 +110,11 @@ describe('NFTMethod', () => { nftID: utils.getRandomBytes(LENGTH_NFT_ID), }; + existingNativeNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: Buffer.concat([ownChainID, Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]), + }; + lockedExistingNFT = { owner: utils.getRandomBytes(LENGTH_ADDRESS), nftID: utils.getRandomBytes(LENGTH_NFT_ID), @@ -102,6 +131,11 @@ describe('NFTMethod', () => { attributesArray: [], }); + await nftStore.save(methodContext, existingNativeNFT.nftID, { + owner: existingNativeNFT.owner, + attributesArray: [], + }); + await userStore.set(methodContext, userStore.getKey(existingNFT.owner, existingNFT.nftID), { lockingModule: NFT_NOT_LOCKED, }); @@ -322,27 +356,11 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id equals own chain id', async () => { - const ownChainID = nftID.slice(0, LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - - const isSupported = await method.isNFTSupported(methodContext, nftID); + const isSupported = await method.isNFTSupported(methodContext, existingNativeNFT.nftID); expect(isSupported).toBe(true); }); it('should return true if nft chain id does not equal own chain id but all nft keys are supported', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { supportedCollectionIDArray: [], }); @@ -352,14 +370,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array is empty', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [], }); @@ -369,14 +379,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array includes collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID) }, @@ -389,14 +391,6 @@ describe('NFTMethod', () => { }); it('should return false if nft chain id does not equal own chain id and nft chain id is supported but corresponding supported collection id array does not include collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, @@ -528,22 +522,17 @@ describe('NFTMethod', () => { ]; const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; const attributesArray3 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; - const config = { - ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; + const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); const address = utils.getRandomBytes(LENGTH_ADDRESS); beforeEach(() => { method.addDependencies(interopMethod, feeMethod); - method.init(config); jest.spyOn(feeMethod, 'payFee'); }); it('should set data to stores with correct key and emit successfull create event when there is no entry in the nft substore', async () => { - const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('0')]); + const expectedKey = Buffer.concat([ownChainID, collectionID, Buffer.from('0')]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); @@ -572,7 +561,7 @@ describe('NFTMethod', () => { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), attributesArray: attributesArray2, }); - const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('2')]); + const expectedKey = Buffer.concat([ownChainID, collectionID, Buffer.from('2')]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); @@ -760,4 +749,455 @@ describe('NFTMethod', () => { expect(lockingModule).toEqual(NFT_NOT_LOCKED); }); }); + + describe('supportAllNFTs', () => { + it('should remove all existing entries, add ALL_SUPPORTED_NFTS_KEY entry and log AllNFTsSupportedEvent', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + await expect(method.supportAllNFTs(methodContext)).resolves.toBeUndefined(); + await expect( + supportedNFTsStore.has(methodContext, ALL_SUPPORTED_NFTS_KEY), + ).resolves.toBeTrue(); + + await expect(supportedNFTsStore.has(methodContext, chainID)).resolves.toBeFalse(); + + checkEventResult(methodContext.eventQueue, 1, AllNFTsSupportedEvent, 0, {}, null); + }); + + it('should not update SupportedNFTsStore if ALL_SUPPORTED_NFTS_KEY entry already exists', async () => { + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + await expect(method.supportAllNFTs(methodContext)).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + }); + + describe('removeSupportAllNFTs', () => { + it('should remove all existing entries and log AllNFTsSupportRemovedEvent', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, utils.getRandomBytes(LENGTH_CHAIN_ID), { + supportedCollectionIDArray: [], + }); + + await expect(method.removeSupportAllNFTs(methodContext)).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.has(methodContext, chainID)).resolves.toBeFalse(); + + checkEventResult(methodContext.eventQueue, 1, AllNFTsSupportRemovedEvent, 0, {}, null); + }); + }); + + describe('supportAllNFTsFromChain', () => { + it('should not update SupportedNFTsStore if provided chainID is equal to ownChainID', async () => { + await expect( + method.supportAllNFTsFromChain(methodContext, ownChainID), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should not update SupportedNFTsStore if ALL_SUPPORTED_NFTS_KEY entry exists', async () => { + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + await expect( + method.supportAllNFTsFromChain(methodContext, utils.getRandomBytes(LENGTH_CHAIN_ID)), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should not update SupportedNFTStore if all collections of provided chainID are already supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + await expect(method.supportAllNFTsFromChain(methodContext, chainID)).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should update SupportedNFTStore if provided chainID does not exist', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await expect(method.supportAllNFTsFromChain(methodContext, chainID)).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.get(methodContext, chainID)).resolves.toEqual({ + supportedCollectionIDArray: [], + }); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromChainSupportedEvent, + 0, + { + chainID, + }, + null, + ); + }); + + it('should update SupportedNFTStore if provided chainID has supported collections', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + ], + }); + + await expect(method.supportAllNFTsFromChain(methodContext, chainID)).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.get(methodContext, chainID)).resolves.toEqual({ + supportedCollectionIDArray: [], + }); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromChainSupportedEvent, + 0, + { + chainID, + }, + null, + ); + }); + }); + + describe('removeSupportAllNFTsFromChain', () => { + it('should throw if provided chainID is equal to ownChainID', async () => { + await expect(method.removeSupportAllNFTsFromChain(methodContext, ownChainID)).rejects.toThrow( + 'Support for native NFTs cannot be removed', + ); + }); + + it('should throw if all NFTs are supported', async () => { + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + await expect( + method.removeSupportAllNFTsFromChain(methodContext, utils.getRandomBytes(LENGTH_CHAIN_ID)), + ).rejects.toThrow('All NFTs from all chains are supported'); + }); + + it('should not update Supported NFTs store if provided chain does not exist', async () => { + await expect( + method.removeSupportAllNFTsFromChain(methodContext, utils.getRandomBytes(LENGTH_CHAIN_ID)), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should remove support for the provided chain and log AllNFTsFromChainSupportedEvent event', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + await expect( + method.removeSupportAllNFTsFromChain(methodContext, chainID), + ).resolves.toBeUndefined(); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromChainSupportRemovedEvent, + 0, + { + chainID, + }, + null, + ); + + await expect(supportedNFTsStore.has(methodContext, chainID)).resolves.toBeFalse(); + }); + }); + + describe('supportAllNFTsFromCollection', () => { + it('should not update SupportedNFTsStore if provided chainID is equal to ownChainID', async () => { + await expect( + method.supportAllNFTsFromCollection( + methodContext, + ownChainID, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should not update SupportedNFTsStore if all NFTs are supported', async () => { + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + await expect( + method.supportAllNFTsFromCollection( + methodContext, + utils.getRandomBytes(LENGTH_CHAIN_ID), + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should not update SupportedNFTsStore if all collections of the provided chain are supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + await expect( + method.supportAllNFTsFromCollection( + methodContext, + chainID, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should not update SupportedNFTsStore if the provided collection is already supported for the provided chain', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_COLLECTION_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID, + }, + ], + }); + + await expect( + method.supportAllNFTsFromCollection(methodContext, chainID, collectionID), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should add the collection to supported collections of the already supported chain lexicographically', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = Buffer.alloc(LENGTH_COLLECTION_ID, 0); + const alreadySupportedCollection = Buffer.alloc(LENGTH_COLLECTION_ID, 1); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID: alreadySupportedCollection, + }, + ], + }); + + await expect( + method.supportAllNFTsFromCollection(methodContext, chainID, collectionID), + ).resolves.toBeUndefined(); + + const expectedSupportedCollectionIDArray = [ + { + collectionID, + }, + { + collectionID: alreadySupportedCollection, + }, + ]; + + await expect(supportedNFTsStore.get(methodContext, chainID)).resolves.toEqual({ + supportedCollectionIDArray: expectedSupportedCollectionIDArray, + }); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromCollectionSupportedEvent, + 0, + { + chainID, + collectionID, + }, + null, + ); + }); + + it('should support the provided collection for the provided chain', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_COLLECTION_ID); + + await expect( + method.supportAllNFTsFromCollection(methodContext, chainID, collectionID), + ).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.get(methodContext, chainID)).resolves.toEqual({ + supportedCollectionIDArray: [{ collectionID }], + }); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromCollectionSupportedEvent, + 0, + { + chainID, + collectionID, + }, + null, + ); + }); + }); + + describe('removeSupportAllNFTsFromCollection', () => { + it('should not update SupportedNFTsStore if provided chainID is equal to ownChainID', async () => { + await expect( + method.removeSupportAllNFTsFromCollection( + methodContext, + ownChainID, + utils.getRandomBytes(LENGTH_CHAIN_ID), + ), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should throw if all NFTs are supported', async () => { + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + await expect( + method.removeSupportAllNFTsFromCollection( + methodContext, + utils.getRandomBytes(LENGTH_CHAIN_ID), + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).rejects.toThrow('All NFTs from all chains are supported'); + }); + + it('should throw if all NFTs for the specified chain are supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + await expect( + method.removeSupportAllNFTsFromCollection( + methodContext, + chainID, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).rejects.toThrow('All NFTs from the specified chain are supported'); + }); + + it('should not update SupportedNFTsStore if collection is not already supported', async () => { + await expect( + method.removeSupportAllNFTsFromCollection( + methodContext, + utils.getRandomBytes(LENGTH_CHAIN_ID), + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ), + ).resolves.toBeUndefined(); + + expect(methodContext.eventQueue.getEvents()).toHaveLength(0); + }); + + it('should remove the support for provided collection and save the remaning supported collections lexicographically', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = Buffer.alloc(LENGTH_CHAIN_ID, 5); + + const supportedCollectionIDArray = [ + { + collectionID: Buffer.alloc(LENGTH_CHAIN_ID, 3), + }, + { + collectionID, + }, + { + collectionID: Buffer.alloc(LENGTH_CHAIN_ID, 7), + }, + ]; + + const expectedSupportedCollectionIDArray = [ + { + collectionID: Buffer.alloc(LENGTH_CHAIN_ID, 3), + }, + { + collectionID: Buffer.alloc(LENGTH_CHAIN_ID, 7), + }, + ]; + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray, + }); + + await expect( + method.removeSupportAllNFTsFromCollection(methodContext, chainID, collectionID), + ).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.get(methodContext, chainID)).resolves.toEqual({ + supportedCollectionIDArray: expectedSupportedCollectionIDArray, + }); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromCollectionSupportRemovedEvent, + 0, + { + collectionID, + chainID, + }, + null, + ); + }); + + it('should remove the entry for provided collection if the only supported collection is removed', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID, + }, + ], + }); + + await expect( + method.removeSupportAllNFTsFromCollection(methodContext, chainID, collectionID), + ).resolves.toBeUndefined(); + + await expect(supportedNFTsStore.has(methodContext, chainID)).resolves.toBeFalse(); + + checkEventResult( + methodContext.eventQueue, + 1, + AllNFTsFromCollectionSupportRemovedEvent, + 0, + { + collectionID, + chainID, + }, + null, + ); + }); + }); }); diff --git a/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts b/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts index 968cfa3bb42..054ad566eaa 100644 --- a/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts +++ b/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts @@ -12,11 +12,12 @@ * Removal or modification of this copyright notice is prohibited. */ +import { utils } from '@liskhq/lisk-cryptography'; import { SupportedNFTsStore } from '../../../../../src/modules/nft/stores/supported_nfts'; import { PrefixedStateReadWriter } from '../../../../../src/state_machine/prefixed_state_read_writer'; import { InMemoryPrefixedStateDB } from '../../../../../src/testing'; import { createStoreGetter } from '../../../../../src/testing/utils'; -import { LENGTH_COLLECTION_ID } from '../../../../../src/modules/nft/constants'; +import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID } from '../../../../../src/modules/nft/constants'; import { CHAIN_ID_LENGTH, StoreGetter } from '../../../../../src'; describe('NFTStore', () => { @@ -62,4 +63,24 @@ describe('NFTStore', () => { }); }); }); + + describe('getAll', () => { + it('should retrieve all NFTs with key between 0 and maximum value for Buffer of length LENGTH_CHAIN_ID', async () => { + await store.save(context, Buffer.alloc(LENGTH_CHAIN_ID, 0), { + supportedCollectionIDArray: [], + }); + + await store.save(context, Buffer.alloc(LENGTH_CHAIN_ID, 1), { + supportedCollectionIDArray: [], + }); + + await store.save(context, utils.getRandomBytes(LENGTH_CHAIN_ID), { + supportedCollectionIDArray: [], + }); + + const allSupportedNFTs = await store.getAll(context); + + expect([...allSupportedNFTs.keys()]).toHaveLength(3); + }); + }); }); diff --git a/framework/test/unit/state_machine/event_queue.spec.ts b/framework/test/unit/state_machine/event_queue.spec.ts index 0f83bcc61a4..7608c5faf9d 100644 --- a/framework/test/unit/state_machine/event_queue.spec.ts +++ b/framework/test/unit/state_machine/event_queue.spec.ts @@ -70,17 +70,6 @@ describe('EventQueue', () => { ).toThrow('Max size of event data is'); }); - it('should throw error if topics is empty', () => { - expect(() => - eventQueue.add( - 'token', - 'Token Event Name', - utils.getRandomBytes(EVENT_MAX_EVENT_SIZE_BYTES), - [], - ), - ).toThrow('Topics must have at least one element'); - }); - it('should throw error if topics length exceeds maxumum allowed', () => { expect(() => eventQueue.add( From a2005c8db8888c6bb66321cc2997034f2b2b6737 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Tue, 13 Jun 2023 22:59:21 +0200 Subject: [PATCH 22/58] Implement methods --- framework/src/modules/nft/constants.ts | 3 + framework/src/modules/nft/events/recover.ts | 6 +- .../src/modules/nft/events/set_attributes.ts | 6 +- .../nft/events/transfer_cross_chain.ts | 15 +- framework/src/modules/nft/internal_method.ts | 3 +- framework/src/modules/nft/method.ts | 211 +++++++++- framework/src/modules/nft/module.ts | 25 +- framework/src/modules/nft/schemas.ts | 8 +- framework/src/modules/nft/types.ts | 13 + .../nft/cc_comands/cc_transfer.spec.ts | 5 +- .../test/unit/modules/nft/method.spec.ts | 374 +++++++++++++++--- 11 files changed, 596 insertions(+), 73 deletions(-) diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index e14f1ded273..475de2ac82b 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -26,6 +26,7 @@ export const EMPTY_BYTES = Buffer.alloc(0); export const ALL_SUPPORTED_NFTS_KEY = EMPTY_BYTES; export const FEE_CREATE_NFT = 5000000; export const LENGTH_TOKEN_ID = 8; +export const MAX_LENGTH_DATA = 64; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, @@ -42,6 +43,8 @@ export const enum NftEventResult { RESULT_RECOVER_FAIL_INVALID_INPUTS = 11, RESULT_INSUFFICIENT_BALANCE = 12, RESULT_DATA_TOO_LONG = 13, + INVALID_RECEIVING_CHAIN = 14, + RESULT_INVALID_ACCOUNT = 15, } export type NftErrorEventResult = Exclude; diff --git a/framework/src/modules/nft/events/recover.ts b/framework/src/modules/nft/events/recover.ts index 589e0585f12..3997f19e7ea 100644 --- a/framework/src/modules/nft/events/recover.ts +++ b/framework/src/modules/nft/events/recover.ts @@ -13,7 +13,7 @@ */ import { BaseEvent, EventQueuer } from '../../base_event'; -import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult } from '../constants'; +import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult, NftErrorEventResult } from '../constants'; export interface RecoverEventData { terminatedChainID: Buffer; @@ -50,4 +50,8 @@ export class RecoverEvent extends BaseEvent { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(senderAddress)) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + throw new Error('Transfer not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userData = await userStore.get(methodContext, userStore.getKey(owner, nftID)); + if (userData.lockingModule !== NFT_NOT_LOCKED) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + throw new Error('Locked NFTs cannot be transferred'); + } + + await this._internalMethod.transferInternal(methodContext, recipientAddress, nftID); + } + + public async transferCrossChain( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + receivingChainID: Buffer, + messageFee: bigint, + data: string, + includeAttributes: boolean, + ): Promise { + if (data.length > MAX_LENGTH_DATA) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_DATA_TOO_LONG, + ); + throw new Error('Data field is too long'); + } + + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(senderAddress)) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + throw new Error('Transfer not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userData = await userStore.get(methodContext, userStore.getKey(owner, nftID)); + if (userData.lockingModule !== NFT_NOT_LOCKED) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + throw new Error('Locked NFTs cannot be transferred'); + } + + const messageFeeTokenID = await this._interoperabilityMethod.getMessageFeeTokenID( + methodContext, + receivingChainID, + ); + const availableBalance = await this._tokenMethod.getAvailableBalance( + methodContext, + senderAddress, + messageFeeTokenID, + ); + if (availableBalance < messageFee) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_INSUFFICIENT_BALANCE, + ); + throw new Error('Insufficient balance for the message fee'); + } + + await this._internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ); + } } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 5518b54092d..abf30662b51 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -38,18 +38,24 @@ import { EscrowStore } from './stores/escrow'; import { NFTStore } from './stores/nft'; import { SupportedNFTsStore } from './stores/supported_nfts'; import { UserStore } from './stores/user'; -import { FeeMethod } from './types'; +import { FeeMethod, TokenMethod } from './types'; +import { CrossChainTransferCommand as CrossChainTransferMessageCommand } from './cc_commands/cc_transfer'; +import { TransferCrossChainCommand } from './commands/transfer_cross_chain'; +import { TransferCommand } from './commands/transfer'; export class NFTModule extends BaseInteroperableModule { public method = new NFTMethod(this.stores, this.events); public endpoint = new NFTEndpoint(this.stores, this.offchainStores); public crossChainMethod = new NFTInteroperableMethod(this.stores, this.events); + public crossChainTransferCommand = new CrossChainTransferMessageCommand(this.stores, this.events); + public crossChainCommand = [this.crossChainTransferCommand]; + private readonly _transferCommand = new TransferCommand(this.stores, this.events); + private readonly _ccTransferCommand = new TransferCrossChainCommand(this.stores, this.events); private readonly _internalMethod = new InternalMethod(this.stores, this.events); - private _interoperabilityMethod!: InteroperabilityMethod; - public commands = []; + public commands = [this._transferCommand, this._ccTransferCommand]; // eslint-disable-next-line no-useless-constructor public constructor() { @@ -84,9 +90,18 @@ export class NFTModule extends BaseInteroperableModule { this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); } - public addDependencies(interoperabilityMethod: InteroperabilityMethod, feeMethod: FeeMethod) { + public addDependencies( + interoperabilityMethod: InteroperabilityMethod, + feeMethod: FeeMethod, + tokenMethod: TokenMethod, + ) { this._interoperabilityMethod = interoperabilityMethod; - this.method.addDependencies(interoperabilityMethod, feeMethod); + this.method.addDependencies( + interoperabilityMethod, + this._internalMethod, + feeMethod, + tokenMethod, + ); this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); this.crossChainMethod.addDependencies(interoperabilityMethod); } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 9b261363e18..506b5216d11 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -12,13 +12,13 @@ * Removal or modification of this copyright notice is prohibited. */ -import { MAX_DATA_LENGTH } from '../token/constants'; import { LENGTH_CHAIN_ID, LENGTH_NFT_ID, LENGTH_TOKEN_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME, + MAX_LENGTH_DATA, } from './constants'; export const transferParamsSchema = { @@ -40,7 +40,7 @@ export const transferParamsSchema = { data: { dataType: 'string', minLength: 0, - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 3, }, }, @@ -90,7 +90,7 @@ export const crossChainNFTTransferMessageParamsSchema = { }, data: { dataType: 'string', - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 5, }, }, @@ -137,7 +137,7 @@ export const crossChainTransferParamsSchema = { data: { dataType: 'string', minLength: 0, - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 4, }, messageFee: { diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index d71c76e83a7..c6b63a9d44f 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -40,3 +40,16 @@ export interface InteroperabilityMethod { export interface FeeMethod { payFee(methodContext: MethodContext, amount: bigint): void; } + +export interface TokenMethod { + getAvailableBalance( + methodContext: MethodContext, + address: Buffer, + tokenID: Buffer, + ): Promise; +} + +export interface NFTMethod { + getChainID(nftID: Buffer): Buffer; + destroy(methodContext: MethodContext, address: Buffer, nftID: Buffer): Promise; +} diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index c84cbee033d..62f7e01c207 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -47,6 +47,9 @@ describe('CrossChain Transfer Command', () => { const method = new NFTMethod(module.stores, module.events); const internalMethod = new InternalMethod(module.stores, module.events); const feeMethod = { payFee: jest.fn() }; + const tokenMethod = { + getAvailableBalance: jest.fn(), + }; const checkEventResult = ( eventQueue: EventQueue, length: number, @@ -128,7 +131,7 @@ describe('CrossChain Transfer Command', () => { beforeEach(async () => { stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); - method.addDependencies(interopMethod, feeMethod); + method.addDependencies(interopMethod, internalMethod, feeMethod, tokenMethod); method.init(config); internalMethod.addDependencies(method, interopMethod); internalMethod.init(config); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 1f14775963f..d4b5a78438e 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -14,6 +14,7 @@ import { codec } from '@liskhq/lisk-codec'; import { utils } from '@liskhq/lisk-cryptography'; +import { when } from 'jest-when'; import { NFTMethod } from '../../../../src/modules/nft/method'; import { NFTModule } from '../../../../src/modules/nft/module'; import { EventQueue } from '../../../../src/state_machine'; @@ -27,6 +28,7 @@ import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, LENGTH_NFT_ID, + LENGTH_TOKEN_ID, NFT_NOT_LOCKED, NftEventResult, } from '../../../../src/modules/nft/constants'; @@ -36,10 +38,33 @@ import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/even import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; import { CreateEvent } from '../../../../src/modules/nft/events/create'; import { LockEvent, LockEventData } from '../../../../src/modules/nft/events/lock'; +import { InternalMethod } from '../../../../src/modules/nft/internal_method'; +import { TransferEvent, TransferEventData } from '../../../../src/modules/nft/events/transfer'; +import { + TransferCrossChainEvent, + TransferCrossChainEventData, +} from '../../../../src/modules/nft/events/transfer_cross_chain'; describe('NFTMethod', () => { const module = new NFTModule(); const method = new NFTMethod(module.stores, module.events); + const internalMethod = new InternalMethod(module.stores, module.events); + const messageFeeTokenID = utils.getRandomBytes(LENGTH_TOKEN_ID); + const interopMethod = { + send: jest.fn(), + error: jest.fn(), + terminateChain: jest.fn(), + getMessageFeeTokenID: jest.fn().mockResolvedValue(Promise.resolve(messageFeeTokenID)), + }; + const feeMethod = { payFee: jest.fn() }; + const tokenMethod = { + getAvailableBalance: jest.fn(), + }; + const config = { + ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; let methodContext!: MethodContext; @@ -73,6 +98,10 @@ describe('NFTMethod', () => { let escrowedNFT: { nftID: any; owner: any }; beforeEach(async () => { + method.addDependencies(interopMethod, internalMethod, feeMethod, tokenMethod); + method.init(config); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(config); owner = utils.getRandomBytes(LENGTH_ADDRESS); methodContext = createMethodContext({ @@ -307,6 +336,7 @@ describe('NFTMethod', () => { }); describe('isNFTSupported', () => { + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); beforeEach(async () => { await nftStore.save(methodContext, nftID, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), @@ -322,27 +352,16 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id equals own chain id', async () => { - const ownChainID = nftID.slice(0, LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - - const isSupported = await method.isNFTSupported(methodContext, nftID); + const newNftID = Buffer.alloc(LENGTH_NFT_ID, 1); + await nftStore.save(methodContext, newNftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + const isSupported = await method.isNFTSupported(methodContext, newNftID); expect(isSupported).toBe(true); }); it('should return true if nft chain id does not equal own chain id but all nft keys are supported', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { supportedCollectionIDArray: [], }); @@ -352,14 +371,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array is empty', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [], }); @@ -369,14 +380,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array includes collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID) }, @@ -389,14 +392,6 @@ describe('NFTMethod', () => { }); it('should return false if nft chain id does not equal own chain id and nft chain id is supported but corresponding supported collection id array does not include collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, @@ -515,30 +510,16 @@ describe('NFTMethod', () => { }); describe('create', () => { - const interopMethod = { - send: jest.fn(), - error: jest.fn(), - terminateChain: jest.fn(), - getMessageFeeTokenID: jest.fn(), - }; - const feeMethod = { payFee: jest.fn() }; const attributesArray1 = [ { module: 'customMod1', attributes: Buffer.alloc(5) }, { module: 'customMod2', attributes: Buffer.alloc(2) }, ]; const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; const attributesArray3 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; - const config = { - ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); const address = utils.getRandomBytes(LENGTH_ADDRESS); beforeEach(() => { - method.addDependencies(interopMethod, feeMethod); - method.init(config); jest.spyOn(feeMethod, 'payFee'); }); @@ -760,4 +741,287 @@ describe('NFTMethod', () => { expect(lockingModule).toEqual(NFT_NOT_LOCKED); }); }); + + describe('transfer', () => { + const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + + it('should throw and emit error transfer event if nft does not exist', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, nftID), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw and emit error transfer event if nft is escrowed', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, escrowedNFT.nftID), + ).rejects.toThrow('NFT is escrowed to another chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID: escrowedNFT.nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should throw and emit error transfer event if transfer is not initiated by the nft owner', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, existingNFT.nftID), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should throw and emit error transfer event if nft is locked', async () => { + await expect( + method.transfer( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + ), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should resolve if all params are valid', async () => { + jest.spyOn(internalMethod, 'transferInternal'); + + await expect( + method.transfer(methodContext, existingNFT.owner, recipientAddress, existingNFT.nftID), + ).resolves.toBeUndefined(); + expect(internalMethod['transferInternal']).toHaveBeenCalledWith( + methodContext, + recipientAddress, + existingNFT.nftID, + ); + }); + }); + + describe('transferCrossChain', () => { + const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const messageFee = BigInt(1000); + const data = ''; + const includeAttributes = false; + + it('should throw and emit error transfer cross chain event if nft does not exist', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw and emit error transfer cross chain event if nft is escrowed', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + escrowedNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT is escrowed to another chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID: escrowedNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should throw and emit error transfer cross chain event if transfer is not initiated by the nft owner', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID: existingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should throw and emit error transfer cross chain event if nft is locked', async () => { + await expect( + method.transferCrossChain( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + receivingChainID, + nftID: lockedExistingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should throw and emit error transfer cross chain event if balance is less than message fee', async () => { + when(tokenMethod.getAvailableBalance) + .calledWith(methodContext, existingNFT.owner, messageFeeTokenID) + .mockResolvedValue(messageFee - BigInt(10)); + + await expect( + method.transferCrossChain( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Insufficient balance for the message fee'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: existingNFT.owner, + recipientAddress, + receivingChainID, + nftID: existingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_INSUFFICIENT_BALANCE, + ); + }); + + it('should resolve if all params are valid', async () => { + jest.spyOn(internalMethod, 'transferCrossChainInternal'); + when(tokenMethod.getAvailableBalance) + .calledWith(methodContext, existingNFT.owner, messageFeeTokenID) + .mockResolvedValue(messageFee + BigInt(10)); + + await expect( + method.transferCrossChain( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + expect(internalMethod['transferCrossChainInternal']).toHaveBeenCalledWith( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ); + }); + }); }); From 478b5c71debbd0e63d477091897bf6e3b99245e0 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 15 Jun 2023 00:41:59 +0200 Subject: [PATCH 23/58] Update verification per feedback --- framework/src/modules/nft/method.ts | 17 +++++++++ .../test/unit/modules/nft/method.spec.ts | 38 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 6026ebf20bb..7e77f8a95f0 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -558,6 +558,23 @@ export class NFTMethod extends BaseMethod { throw new Error('NFT is escrowed to another chain'); } + const nftChainID = this.getChainID(nftID); + const ownChainID = this._internalMethod.getOwnChainID(); + if (![ownChainID, receivingChainID].some(allowedChainID => nftChainID.equals(allowedChainID))) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_NOT_NATIVE, + ); + throw new Error('NFT must be native either to the sending chain or the receiving chain'); + } + if (!owner.equals(senderAddress)) { this.events.get(TransferCrossChainEvent).error( methodContext, diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index d4b5a78438e..301c8151382 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -840,12 +840,17 @@ describe('NFTMethod', () => { describe('transferCrossChain', () => { const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); - const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); const messageFee = BigInt(1000); const data = ''; const includeAttributes = false; + let receivingChainID: Buffer; + + beforeEach(() => { + receivingChainID = existingNFT.nftID.slice(0, LENGTH_CHAIN_ID); + }); it('should throw and emit error transfer cross chain event if nft does not exist', async () => { + receivingChainID = nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, @@ -875,6 +880,7 @@ describe('NFTMethod', () => { }); it('should throw and emit error transfer cross chain event if nft is escrowed', async () => { + receivingChainID = escrowedNFT.nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, @@ -903,6 +909,35 @@ describe('NFTMethod', () => { ); }); + it('should throw and emit error transfer cross chain event if nft chain id is equal to neither own chain id or receiving chain id', async () => { + await expect( + method.transferCrossChain( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT must be native either to the sending chain or the receiving chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + receivingChainID, + nftID: lockedExistingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_NOT_NATIVE, + ); + }); + it('should throw and emit error transfer cross chain event if transfer is not initiated by the nft owner', async () => { await expect( method.transferCrossChain( @@ -933,6 +968,7 @@ describe('NFTMethod', () => { }); it('should throw and emit error transfer cross chain event if nft is locked', async () => { + receivingChainID = lockedExistingNFT.nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, From 5c65e808e1305e05adc27656dd401f95c09bbcec Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 15 Jun 2023 17:55:07 +0200 Subject: [PATCH 24/58] Implement nft methods --- framework/src/modules/nft/method.ts | 128 +++++++- .../test/unit/modules/nft/method.spec.ts | 273 +++++++++++++++++- 2 files changed, 399 insertions(+), 2 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 8f4f2513674..01a7bfe2a3c 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -12,13 +12,15 @@ * Removal or modification of this copyright notice is prohibited. */ +import { codec } from '@liskhq/lisk-codec'; import { BaseMethod } from '../base_method'; import { FeeMethod, InteroperabilityMethod, ModuleConfig, TokenMethod } from './types'; -import { NFTAttributes, NFTStore } from './stores/nft'; +import { NFTAttributes, NFTStore, NFTStoreData, nftStoreSchema } from './stores/nft'; import { ImmutableMethodContext, MethodContext } from '../../state_machine'; import { ALL_SUPPORTED_NFTS_KEY, FEE_CREATE_NFT, + LENGTH_ADDRESS, LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, LENGTH_NFT_ID, @@ -40,6 +42,9 @@ import { AllNFTsFromChainSupportedEvent } from './events/all_nfts_from_chain_sup import { AllNFTsFromCollectionSupportedEvent } from './events/all_nfts_from_collection_suppported'; import { AllNFTsFromCollectionSupportRemovedEvent } from './events/all_nfts_from_collection_support_removed'; import { AllNFTsFromChainSupportRemovedEvent } from './events/all_nfts_from_chain_support_removed'; +import { RecoverEvent } from './events/recover'; +import { EscrowStore } from './stores/escrow'; +import { SetAttributesEvent } from './events/set_attributes'; export class NFTMethod extends BaseMethod { private _config!: ModuleConfig; @@ -854,4 +859,125 @@ export class NFTMethod extends BaseMethod { collectionID, }); } + + public async recover( + methodContext: MethodContext, + terminatedChainID: Buffer, + substorePrefix: Buffer, + storeKey: Buffer, + storeValue: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const nftID = storeKey; + let isDecodable = true; + let decodedValue: NFTStoreData; + try { + decodedValue = codec.decode(nftStoreSchema, storeValue); + } catch (error) { + isDecodable = false; + } + + if ( + !substorePrefix.equals(nftStore.subStorePrefix) || + storeKey.length !== LENGTH_NFT_ID || + !isDecodable + ) { + this.events.get(RecoverEvent).error( + methodContext, + { + terminatedChainID, + nftID, + }, + NftEventResult.RESULT_RECOVER_FAIL_INVALID_INPUTS, + ); + throw new Error('Invalid inputs'); + } + + const nftChainID = this.getChainID(nftID); + const ownChainID = this._internalMethod.getOwnChainID(); + if (!nftChainID.equals(ownChainID)) { + this.events.get(RecoverEvent).error( + methodContext, + { + terminatedChainID, + nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONNATIVE_CHAIN, + ); + throw new Error('Recovery called by a foreign chain'); + } + + const nftData = await nftStore.get(methodContext, nftID); + if (!nftData.owner.equals(terminatedChainID)) { + this.events.get(RecoverEvent).error( + methodContext, + { + terminatedChainID, + nftID, + }, + NftEventResult.RESULT_NFT_NOT_ESCROWED, + ); + throw new Error('NFT was not escrowed to terminated chain'); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const storeValueOwner = decodedValue!.owner; + if (storeValueOwner.length !== LENGTH_ADDRESS) { + this.events.get(RecoverEvent).error( + methodContext, + { + terminatedChainID, + nftID, + }, + NftEventResult.RESULT_INVALID_ACCOUNT, + ); + throw new Error('Invalid account information'); + } + + const escrowStore = this.stores.get(EscrowStore); + nftData.owner = storeValueOwner; + await nftStore.save(methodContext, nftID, nftData); + await this._internalMethod.createUserEntry(methodContext, nftData.owner, nftID); + await escrowStore.del(methodContext, escrowStore.getKey(terminatedChainID, nftID)); + + this.events.get(RecoverEvent).log(methodContext, { + terminatedChainID, + nftID, + }); + } + + public async setAttributes( + methodContext: MethodContext, + module: string, + nftID: Buffer, + attributes: Buffer, + ): Promise { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + this.events.get(SetAttributesEvent).error( + methodContext, + { + nftID, + attributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + throw new Error('NFT substore entry does not exist'); + } + + const nftData = await nftStore.get(methodContext, nftID); + const index = nftData.attributesArray.findIndex(attr => attr.module === module); + if (index > -1) { + nftData.attributesArray[index] = { module, attributes }; + } else { + nftData.attributesArray.push({ module, attributes }); + } + await nftStore.save(methodContext, nftID, nftData); + + this.events.get(SetAttributesEvent).log(methodContext, { + nftID, + attributes, + }); + } } diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 345ddce4d60..6455cedefe3 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -32,7 +32,7 @@ import { NFT_NOT_LOCKED, NftEventResult, } from '../../../../src/modules/nft/constants'; -import { NFTStore } from '../../../../src/modules/nft/stores/nft'; +import { NFTStore, nftStoreSchema } from '../../../../src/modules/nft/stores/nft'; import { UserStore } from '../../../../src/modules/nft/stores/user'; import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; @@ -62,6 +62,12 @@ import { AllNFTsFromChainSupportRemovedEvent, AllNFTsFromChainSupportRemovedEventData, } from '../../../../src/modules/nft/events/all_nfts_from_chain_support_removed'; +import { RecoverEvent, RecoverEventData } from '../../../../src/modules/nft/events/recover'; +import { + SetAttributesEvent, + SetAttributesEventData, +} from '../../../../src/modules/nft/events/set_attributes'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; describe('NFTMethod', () => { const module = new NFTModule(); @@ -1538,4 +1544,269 @@ describe('NFTMethod', () => { ); }); }); + + describe('recover', () => { + const terminatedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const substorePrefix = Buffer.from('8000', 'hex'); + const storeKey = utils.getRandomBytes(LENGTH_NFT_ID); + const storeValue = codec.encode(nftStoreSchema, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + + it('should throw and emit error recover event if substore prefix is not valid', async () => { + await expect( + method.recover(methodContext, terminatedChainID, Buffer.alloc(2, 2), storeKey, storeValue), + ).rejects.toThrow('Invalid inputs'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: storeKey, + }, + NftEventResult.RESULT_RECOVER_FAIL_INVALID_INPUTS, + ); + }); + + it('should throw and emit error recover event if store key length is not valid', async () => { + const newStoreKey = utils.getRandomBytes(LENGTH_NFT_ID + 1); + + await expect( + method.recover(methodContext, terminatedChainID, substorePrefix, newStoreKey, storeValue), + ).rejects.toThrow('Invalid inputs'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: newStoreKey, + }, + NftEventResult.RESULT_RECOVER_FAIL_INVALID_INPUTS, + ); + }); + + it('should throw and emit error recover event if store value is not valid', async () => { + await expect( + method.recover( + methodContext, + terminatedChainID, + substorePrefix, + storeKey, + Buffer.from('asfas'), + ), + ).rejects.toThrow('Invalid inputs'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: storeKey, + }, + NftEventResult.RESULT_RECOVER_FAIL_INVALID_INPUTS, + ); + }); + + it('should throw and emit error recover event if nft chain id is not same as own chain id', async () => { + await expect( + method.recover(methodContext, terminatedChainID, substorePrefix, storeKey, storeValue), + ).rejects.toThrow('Recovery called by a foreign chain'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: storeKey, + }, + NftEventResult.RESULT_INITIATED_BY_NONNATIVE_CHAIN, + ); + }); + + it('should throw and emit error recover event if nft is not escrowed to terminated chain', async () => { + const newStoreKey = Buffer.alloc(LENGTH_NFT_ID, 1); + await nftStore.save(methodContext, newStoreKey, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + + await expect( + method.recover(methodContext, terminatedChainID, substorePrefix, newStoreKey, storeValue), + ).rejects.toThrow('NFT was not escrowed to terminated chain'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: newStoreKey, + }, + NftEventResult.RESULT_NFT_NOT_ESCROWED, + ); + }); + + it('should throw and emit error recover event if store value owner length is invalid', async () => { + const newStoreKey = Buffer.alloc(LENGTH_NFT_ID, 1); + await nftStore.save(methodContext, newStoreKey, { + owner: terminatedChainID, + attributesArray: [], + }); + + await expect( + method.recover(methodContext, terminatedChainID, substorePrefix, newStoreKey, storeValue), + ).rejects.toThrow('Invalid account information'); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: newStoreKey, + }, + NftEventResult.RESULT_INVALID_ACCOUNT, + ); + }); + + it('should set appropriate values to stores and resolve with emitting success recover event if params are valid', async () => { + const newStoreKey = Buffer.alloc(LENGTH_NFT_ID, 1); + const storeValueOwner = utils.getRandomBytes(LENGTH_ADDRESS); + const newStoreValue = codec.encode(nftStoreSchema, { + owner: storeValueOwner, + attributesArray: [], + }); + await nftStore.save(methodContext, newStoreKey, { + owner: terminatedChainID, + attributesArray: [], + }); + jest.spyOn(internalMethod, 'createUserEntry'); + + await expect( + method.recover( + methodContext, + terminatedChainID, + substorePrefix, + newStoreKey, + newStoreValue, + ), + ).resolves.toBeUndefined(); + checkEventResult( + methodContext.eventQueue, + 1, + RecoverEvent, + 0, + { + terminatedChainID, + nftID: newStoreKey, + }, + NftEventResult.RESULT_SUCCESSFUL, + ); + const nftStoreData = await nftStore.get(methodContext, newStoreKey); + const escrowStore = module.stores.get(EscrowStore); + const escrowAccountExists = await escrowStore.has( + methodContext, + escrowStore.getKey(terminatedChainID, newStoreKey), + ); + expect(nftStoreData.owner).toStrictEqual(storeValueOwner); + expect(nftStoreData.attributesArray).toEqual([]); + expect(internalMethod['createUserEntry']).toHaveBeenCalledWith( + methodContext, + storeValueOwner, + newStoreKey, + ); + expect(escrowAccountExists).toBe(false); + }); + }); + + describe('setAttributes', () => { + it('should throw and log LockEvent if NFT does not exist', async () => { + const attributes = Buffer.alloc(9); + + await expect( + method.setAttributes(methodContext, module.name, nftID, attributes), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + SetAttributesEvent, + 0, + { + nftID, + attributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should set attributes if NFT exists and no entry exists for the given module', async () => { + const attributes = Buffer.alloc(7); + + await expect( + method.setAttributes(methodContext, module.name, existingNFT.nftID, attributes), + ).resolves.toBeUndefined(); + checkEventResult( + methodContext.eventQueue, + 1, + SetAttributesEvent, + 0, + { + nftID: existingNFT.nftID, + attributes, + }, + NftEventResult.RESULT_SUCCESSFUL, + ); + const storedAttributes = await method.getAttributes( + methodContext, + module.name, + existingNFT.nftID, + ); + expect(storedAttributes).toStrictEqual(attributes); + }); + + it('should update attributes if NFT exists and an entry already exists for the given module', async () => { + const newAttributes = Buffer.alloc(12); + const attributesArray1 = [ + { module: 'customMod1', attributes: Buffer.alloc(5) }, + { module: 'customMod2', attributes: Buffer.alloc(2) }, + ]; + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray1, + }); + + await expect( + method.setAttributes( + methodContext, + attributesArray1[0].module, + existingNFT.nftID, + newAttributes, + ), + ).resolves.toBeUndefined(); + checkEventResult( + methodContext.eventQueue, + 1, + SetAttributesEvent, + 0, + { + nftID: existingNFT.nftID, + attributes: newAttributes, + }, + NftEventResult.RESULT_SUCCESSFUL, + ); + const storedAttributes = await method.getAttributes( + methodContext, + attributesArray1[0].module, + existingNFT.nftID, + ); + expect(storedAttributes).toStrictEqual(newAttributes); + }); + }); }); From 128433b3effcaa91f8f1f8ccd67fcc72cca2e4d0 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Mon, 19 Jun 2023 05:00:03 +0200 Subject: [PATCH 25/58] Update framework/test/unit/modules/nft/method.spec.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miroslav Jerković --- framework/test/unit/modules/nft/method.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 6455cedefe3..2dd3d81568a 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -1727,7 +1727,7 @@ describe('NFTMethod', () => { }); describe('setAttributes', () => { - it('should throw and log LockEvent if NFT does not exist', async () => { + it('should throw and log SetAttributesEvent if NFT does not exist', async () => { const attributes = Buffer.alloc(9); await expect( From fd20e6d5d438dfcc321f7336f99d270ec81549c7 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Mon, 19 Jun 2023 05:48:19 +0200 Subject: [PATCH 26/58] Update to use redundant function per feedback --- framework/src/modules/nft/cc_commands/cc_transfer.ts | 8 ++++++-- framework/src/modules/nft/method.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts index 9a2331926d0..af4045cd2a8 100644 --- a/framework/src/modules/nft/cc_commands/cc_transfer.ts +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -104,8 +104,12 @@ export class CrossChainTransferCommand extends BaseCCCommand { const storeData = await nftStore.get(getMethodContext(), nftID); if (status === CCM_STATUS_CODE_OK) { storeData.owner = recipientAddress; - // commented line below can be used by custom modules when defining their own logic for getNewAttributes function - // storeData.attributesArray = this._internalMethod.getNewAttributes(nftID, storeData.attributesArray, params.attributesArray); + const storedAttributes = storeData.attributesArray; + storeData.attributesArray = this._internalMethod.getNewAttributes( + nftID, + storedAttributes, + receivedAttributes, + ); await nftStore.save(getMethodContext(), nftID, storeData); await this._internalMethod.createUserEntry(getMethodContext(), recipientAddress, nftID); await escrowStore.del(getMethodContext(), escrowStore.getKey(sendingChainID, nftID)); diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 01a7bfe2a3c..bee060c6eee 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -936,6 +936,14 @@ export class NFTMethod extends BaseMethod { const escrowStore = this.stores.get(EscrowStore); nftData.owner = storeValueOwner; + const storedAttributes = nftData.attributesArray; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const receivedAttributes = decodedValue!.attributesArray; + nftData.attributesArray = this._internalMethod.getNewAttributes( + nftID, + storedAttributes, + receivedAttributes, + ); await nftStore.save(methodContext, nftID, nftData); await this._internalMethod.createUserEntry(methodContext, nftData.owner, nftID); await escrowStore.del(methodContext, escrowStore.getKey(terminatedChainID, nftID)); From 267deac1507a249ee5b1189d66e4ce20cbdd4003 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Mon, 19 Jun 2023 22:49:49 +0200 Subject: [PATCH 27/58] NFTEndpoint (#8591) * :recycle: Updates signature of NFTMethod.getCollectionID & NFTMethod.isNFTSupported * :seedling: NFTEndpoint * :bug: :white_check_mark: for NFTEndpoint.getNFT * :pencil2: * :bug: Fixes getNFTsResponseSchema defintion * :white_check_mark: Adds schema validation logic for NFTEndpoints --- framework/src/modules/nft/endpoint.ts | 221 ++++- framework/src/modules/nft/method.ts | 10 +- framework/src/modules/nft/module.ts | 54 +- framework/src/modules/nft/schemas.ts | 231 ++++++ framework/src/modules/nft/types.ts | 11 + .../test/unit/modules/nft/endpoint.spec.ts | 757 ++++++++++++++++++ 6 files changed, 1276 insertions(+), 8 deletions(-) create mode 100644 framework/test/unit/modules/nft/endpoint.spec.ts diff --git a/framework/src/modules/nft/endpoint.ts b/framework/src/modules/nft/endpoint.ts index aa2637fa295..1999d881077 100644 --- a/framework/src/modules/nft/endpoint.ts +++ b/framework/src/modules/nft/endpoint.ts @@ -12,14 +12,225 @@ * Removal or modification of this copyright notice is prohibited. */ -import { ModuleConfig } from './types'; +import * as cryptography from '@liskhq/lisk-cryptography'; +import { validator } from '@liskhq/lisk-validator'; import { BaseEndpoint } from '../base_endpoint'; +import { JSONObject, ModuleEndpointContext } from '../../types'; +import { + collectionExistsRequestSchema, + getCollectionIDsRequestSchema, + getEscrowedNFTIDsRequestSchema, + getNFTRequestSchema, + getNFTsRequestSchema, + hasNFTRequestSchema, + isNFTSupportedRequestSchema, +} from './schemas'; +import { NFTStore } from './stores/nft'; +import { LENGTH_NFT_ID } from './constants'; +import { UserStore } from './stores/user'; +import { NFT } from './types'; +import { SupportedNFTsStore } from './stores/supported_nfts'; +import { NFTMethod } from './method'; export class NFTEndpoint extends BaseEndpoint { - // @ts-expect-error TODO: unused error. Remove when implementing. - private _moduleConfig!: ModuleConfig; + private _nftMethod!: NFTMethod; - public init(moduleConfig: ModuleConfig) { - this._moduleConfig = moduleConfig; + public addDependencies(nftMethod: NFTMethod) { + this._nftMethod = nftMethod; + } + + public async getNFTs( + context: ModuleEndpointContext, + ): Promise<{ nfts: JSONObject & { id: string }>[] }> { + validator.validate<{ address: string }>(getNFTsRequestSchema, context.params); + + const nftStore = this.stores.get(NFTStore); + + const owner = cryptography.address.getAddressFromLisk32Address(context.params.address); + + const allNFTs = await nftStore.iterate(context.getImmutableMethodContext(), { + gte: Buffer.alloc(LENGTH_NFT_ID, 0), + lte: Buffer.alloc(LENGTH_NFT_ID, 255), + }); + + const ownedNFTs = allNFTs.filter(nft => nft.value.owner.equals(owner)); + + const userStore = this.stores.get(UserStore); + + const nfts = []; + + for (const ownedNFT of ownedNFTs) { + const ownedNFTUserData = await userStore.get( + context.getImmutableMethodContext(), + userStore.getKey(owner, ownedNFT.key), + ); + + nfts.push({ + id: ownedNFT.key.toString('hex'), + attributesArray: ownedNFT.value.attributesArray.map(attribute => ({ + module: attribute.module, + attributes: attribute.attributes.toString('hex'), + })), + lockingModule: ownedNFTUserData.lockingModule, + }); + } + + return { nfts }; + } + + public async hasNFT(context: ModuleEndpointContext): Promise<{ hasNFT: boolean }> { + const { params } = context; + validator.validate<{ address: string; id: string }>(hasNFTRequestSchema, params); + + const nftID = Buffer.from(params.id, 'hex'); + const owner = cryptography.address.getAddressFromLisk32Address(params.address); + + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(context.getImmutableMethodContext(), nftID); + + if (!nftExists) { + return { hasNFT: nftExists }; + } + + const nftData = await nftStore.get(context.getImmutableMethodContext(), nftID); + + return { hasNFT: nftData.owner.equals(owner) }; + } + + public async getNFT(context: ModuleEndpointContext): Promise> { + const { params } = context; + validator.validate<{ id: string }>(getNFTRequestSchema, params); + + const nftID = Buffer.from(params.id, 'hex'); + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(context.getImmutableMethodContext(), nftID); + + if (!nftExists) { + throw new Error('NFT does not exist'); + } + + const userStore = this.stores.get(UserStore); + const nftData = await nftStore.get(context.getImmutableMethodContext(), nftID); + const userData = await userStore.get( + context.getImmutableMethodContext(), + userStore.getKey(nftData.owner, nftID), + ); + + return { + owner: nftData.owner.toString('hex'), + attributesArray: nftData.attributesArray.map(attribute => ({ + module: attribute.module, + attributes: attribute.attributes.toString('hex'), + })), + lockingModule: userData.lockingModule, + }; + } + + public async getCollectionIDs( + context: ModuleEndpointContext, + ): Promise<{ collectionIDs: string[] }> { + const { params } = context; + + validator.validate<{ chainID: string }>(getCollectionIDsRequestSchema, params); + + const chainID = Buffer.from(params.chainID, 'hex'); + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const chainExists = await supportedNFTsStore.has(context.getImmutableMethodContext(), chainID); + + if (!chainExists) { + return { collectionIDs: [] }; + } + + const supportedNFTsData = await supportedNFTsStore.get( + context.getImmutableMethodContext(), + chainID, + ); + + return { + collectionIDs: supportedNFTsData.supportedCollectionIDArray.map(collection => + collection.collectionID.toString('hex'), + ), + }; + } + + public async collectionExists( + context: ModuleEndpointContext, + ): Promise<{ collectionExists: boolean }> { + const { params } = context; + + validator.validate<{ chainID: string; collectionID: string }>( + collectionExistsRequestSchema, + params, + ); + + const chainID = Buffer.from(params.chainID, 'hex'); + + const supportedNFTsStore = this.stores.get(SupportedNFTsStore); + + const chainExists = await supportedNFTsStore.has(context.getImmutableMethodContext(), chainID); + + if (!chainExists) { + return { collectionExists: false }; + } + + const collectionID = Buffer.from(params.collectionID, 'hex'); + + const supportedNFTsData = await supportedNFTsStore.get( + context.getImmutableMethodContext(), + chainID, + ); + + return { + collectionExists: supportedNFTsData.supportedCollectionIDArray.some(supportedCollection => + supportedCollection.collectionID.equals(collectionID), + ), + }; + } + + public async getEscrowedNFTIDs( + context: ModuleEndpointContext, + ): Promise<{ escrowedNFTIDs: string[] }> { + const { params } = context; + + validator.validate<{ chainID: string }>(getEscrowedNFTIDsRequestSchema, params); + + const chainD = Buffer.from(params.chainID, 'hex'); + + const nftStore = this.stores.get(NFTStore); + + const allNFTs = await nftStore.iterate(context.getImmutableMethodContext(), { + gte: Buffer.alloc(LENGTH_NFT_ID, 0), + lte: Buffer.alloc(LENGTH_NFT_ID, 255), + }); + + return { + escrowedNFTIDs: allNFTs + .filter(nft => nft.value.owner.equals(chainD)) + .map(nft => nft.key.toString('hex')), + }; + } + + public async isNFTSupported( + context: ModuleEndpointContext, + ): Promise<{ isNFTSupported: boolean }> { + const { params } = context; + + validator.validate<{ id: string }>(isNFTSupportedRequestSchema, params); + + const nftID = Buffer.from(params.id, 'hex'); + let isNFTSupported = false; + + try { + isNFTSupported = await this._nftMethod.isNFTSupported( + context.getImmutableMethodContext(), + nftID, + ); + } catch (err) { + return { isNFTSupported }; + } + + return { isNFTSupported }; } } diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 8f4f2513674..8bafcd26ad9 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -179,7 +179,10 @@ export class NFTMethod extends BaseMethod { }); } - public async getCollectionID(methodContext: MethodContext, nftID: Buffer): Promise { + public async getCollectionID( + methodContext: ImmutableMethodContext, + nftID: Buffer, + ): Promise { const nftStore = this.stores.get(NFTStore); const nftExists = await nftStore.has(methodContext, nftID); if (!nftExists) { @@ -188,7 +191,10 @@ export class NFTMethod extends BaseMethod { return nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); } - public async isNFTSupported(methodContext: MethodContext, nftID: Buffer): Promise { + public async isNFTSupported( + methodContext: ImmutableMethodContext, + nftID: Buffer, + ): Promise { const nftStore = this.stores.get(NFTStore); const nftExists = await nftStore.has(methodContext, nftID); if (!nftExists) { diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index a2a8fab8dce..9dce479a4ec 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -35,6 +35,22 @@ import { TransferCrossChainEvent } from './events/transfer_cross_chain'; import { UnlockEvent } from './events/unlock'; import { InternalMethod } from './internal_method'; import { NFTMethod } from './method'; +import { + collectionExistsRequestSchema, + collectionExistsResponseSchema, + getCollectionIDsRequestSchema, + getCollectionIDsResponseSchema, + getEscrowedNFTIDsRequestSchema, + getEscrowedNFTIDsResponseSchema, + getNFTRequestSchema, + getNFTResponseSchema, + getNFTsRequestSchema, + getNFTsResponseSchema, + hasNFTRequestSchema, + hasNFTResponseSchema, + isNFTSupportedRequestSchema, + isNFTSupportedResponseSchema, +} from './schemas'; import { EscrowStore } from './stores/escrow'; import { NFTStore } from './stores/nft'; import { SupportedNFTsStore } from './stores/supported_nfts'; @@ -114,7 +130,43 @@ export class NFTModule extends BaseInteroperableModule { public metadata(): ModuleMetadata { return { ...this.baseMetadata(), - endpoints: [], + endpoints: [ + { + name: this.endpoint.collectionExists.name, + request: collectionExistsRequestSchema, + response: collectionExistsResponseSchema, + }, + { + name: this.endpoint.getCollectionIDs.name, + request: getCollectionIDsRequestSchema, + response: getCollectionIDsResponseSchema, + }, + { + name: this.endpoint.getEscrowedNFTIDs.name, + request: getEscrowedNFTIDsRequestSchema, + response: getEscrowedNFTIDsResponseSchema, + }, + { + name: this.endpoint.getNFT.name, + request: getNFTRequestSchema, + response: getNFTResponseSchema, + }, + { + name: this.endpoint.getNFTs.name, + request: getNFTsRequestSchema, + response: getNFTsResponseSchema, + }, + { + name: this.endpoint.hasNFT.name, + request: hasNFTRequestSchema, + response: hasNFTResponseSchema, + }, + { + name: this.endpoint.isNFTSupported.name, + request: isNFTSupportedRequestSchema, + response: isNFTSupportedResponseSchema, + }, + ], assets: [], }; } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 506b5216d11..da8f393bc6c 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -14,6 +14,7 @@ import { LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, LENGTH_NFT_ID, LENGTH_TOKEN_ID, MAX_LENGTH_MODULE_NAME, @@ -156,3 +157,233 @@ export const crossChainTransferParamsSchema = { }, }, }; + +export const getNFTsRequestSchema = { + $id: '/nft/endpoint/getNFTsRequest', + type: 'object', + properties: { + address: { + type: 'string', + format: 'lisk32', + }, + }, + required: ['address'], +}; + +export const getNFTsResponseSchema = { + $id: '/nft/endpoint/getNFTsResponse', + type: 'object', + properties: { + nfts: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'hex', + }, + attributesArray: { + type: 'array', + items: { + type: 'object', + properties: { + module: { + type: 'string', + }, + attributes: { + type: 'string', + format: 'hex', + }, + }, + }, + }, + lockingModule: { + type: 'string', + }, + }, + }, + }, + }, +}; + +export const hasNFTRequestSchema = { + $id: '/nft/endpoint/hasNFTRequest', + type: 'object', + properties: { + address: { + type: 'string', + format: 'lisk32', + }, + id: { + type: 'string', + format: 'hex', + minLength: LENGTH_NFT_ID * 2, + maxLength: LENGTH_NFT_ID * 2, + }, + }, + required: ['address', 'id'], +}; + +export const hasNFTResponseSchema = { + $id: '/nft/endpoint/hasNFTResponse', + type: 'object', + properties: { + hasNFT: { + type: 'boolean', + }, + }, +}; + +export const getNFTRequestSchema = { + $id: '/nft/endpoint/getNFTRequest', + type: 'object', + properties: { + id: { + type: 'string', + format: 'hex', + minLength: LENGTH_NFT_ID * 2, + maxLength: LENGTH_NFT_ID * 2, + }, + }, + required: ['id'], +}; + +export const getNFTResponseSchema = { + $id: '/nft/endpoint/getNFTResponse', + type: 'object', + properties: { + owner: { + type: 'string', + format: 'hex', + }, + attributesArray: { + type: 'array', + items: { + type: 'object', + properties: { + module: { + type: 'string', + }, + attributes: { + type: 'string', + format: 'hex', + }, + }, + }, + }, + lockingModule: { + type: 'string', + }, + }, +}; + +export const getCollectionIDsRequestSchema = { + $id: '/nft/endpoint/getCollectionIDsRequest', + type: 'object', + properties: { + chainID: { + type: 'string', + format: 'hex', + minLength: LENGTH_CHAIN_ID * 2, + maxLength: LENGTH_CHAIN_ID * 2, + }, + }, + required: ['chainID'], +}; + +export const getCollectionIDsResponseSchema = { + $id: '/nft/endpoint/getCollectionIDsRespone', + type: 'object', + properties: { + collectionIDs: { + type: 'array', + items: { + type: 'string', + format: 'hex', + }, + }, + }, +}; + +export const collectionExistsRequestSchema = { + $id: '/nft/endpoint/collectionExistsRequest', + type: 'object', + properties: { + chainID: { + type: 'string', + format: 'hex', + minLength: LENGTH_CHAIN_ID * 2, + maxLength: LENGTH_CHAIN_ID * 2, + }, + collectionID: { + type: 'string', + format: 'hex', + minLength: LENGTH_COLLECTION_ID * 2, + maxLength: LENGTH_COLLECTION_ID * 2, + }, + }, + required: ['chainID', 'collectionID'], +}; + +export const collectionExistsResponseSchema = { + $id: '/nft/endpoint/collectionExistsResponse', + type: 'object', + properties: { + collectionExists: { + type: 'boolean', + }, + }, +}; + +export const getEscrowedNFTIDsRequestSchema = { + $id: '/nft/endpoint/getEscrowedNFTIDsRequest', + type: 'object', + properties: { + chainID: { + type: 'string', + format: 'hex', + minLength: LENGTH_CHAIN_ID * 2, + maxLength: LENGTH_CHAIN_ID * 2, + }, + }, + required: ['chainID'], +}; + +export const getEscrowedNFTIDsResponseSchema = { + $id: '/nft/endpoint/getEscrowedNFTIDsResponse', + type: 'object', + properties: { + escrowedNFTIDs: { + type: 'array', + items: { + type: 'string', + format: 'hex', + }, + }, + }, +}; + +export const isNFTSupportedRequestSchema = { + $id: '/nft/endpoint/isNFTSupportedRequest', + type: 'object', + properties: { + id: { + type: 'string', + format: 'hex', + minLength: LENGTH_NFT_ID * 2, + maxLength: LENGTH_NFT_ID * 2, + }, + }, + required: ['id'], +}; + +export const isNFTSupportedResponseSchema = { + $id: '/nft/endpoint/isNFTSupportedResponse', + type: 'object', + properties: { + isNFTSupported: { + type: 'boolean', + }, + }, +}; diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index c6b63a9d44f..bfa88ef2189 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -53,3 +53,14 @@ export interface NFTMethod { getChainID(nftID: Buffer): Buffer; destroy(methodContext: MethodContext, address: Buffer, nftID: Buffer): Promise; } + +export interface NFTAttributes { + module: string; + attributes: Buffer; +} + +export interface NFT { + owner: string; + attributesArray: NFTAttributes[]; + lockingModule: string; +} diff --git a/framework/test/unit/modules/nft/endpoint.spec.ts b/framework/test/unit/modules/nft/endpoint.spec.ts new file mode 100644 index 00000000000..d179db7dbeb --- /dev/null +++ b/framework/test/unit/modules/nft/endpoint.spec.ts @@ -0,0 +1,757 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { validator } from '@liskhq/lisk-validator'; +import { address, utils } from '@liskhq/lisk-cryptography'; +import { NFTEndpoint } from '../../../../src/modules/nft/endpoint'; +import { NFTMethod } from '../../../../src/modules/nft/method'; +import { NFTModule } from '../../../../src/modules/nft/module'; +import { MethodContext } from '../../../../src/state_machine'; +import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; +import { + InMemoryPrefixedStateDB, + createTransientMethodContext, + createTransientModuleEndpointContext, +} from '../../../../src/testing'; +import { NFTStore } from '../../../../src/modules/nft/stores/nft'; +import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { + ALL_SUPPORTED_NFTS_KEY, + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + NFT_NOT_LOCKED, +} from '../../../../src/modules/nft/constants'; +import { NFT } from '../../../../src/modules/nft/types'; +import { JSONObject } from '../../../../src'; +import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; +import { + collectionExistsResponseSchema, + getCollectionIDsResponseSchema, + getEscrowedNFTIDsResponseSchema, + getNFTResponseSchema, + getNFTsResponseSchema, + hasNFTResponseSchema, + isNFTSupportedResponseSchema, +} from '../../../../src/modules/nft/schemas'; + +type NFTofOwner = Omit & { id: Buffer }; + +describe('NFTEndpoint', () => { + const module = new NFTModule(); + const method = new NFTMethod(module.stores, module.events); + const endpoint = new NFTEndpoint(module.stores, module.events); + const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + method.init({ ownChainID }); + + endpoint.addDependencies(method); + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + + let stateStore: PrefixedStateReadWriter; + let methodContext: MethodContext; + + const owner = utils.getRandomBytes(LENGTH_ADDRESS); + const ownerAddress = address.getLisk32AddressFromAddress(owner); + + const nfts: NFTofOwner[] = [ + { + id: utils.getRandomBytes(LENGTH_NFT_ID), + attributesArray: [ + { + module: 'pos', + attributes: Buffer.alloc(10, 0), + }, + ], + lockingModule: NFT_NOT_LOCKED, + }, + { + id: utils.getRandomBytes(LENGTH_NFT_ID), + attributesArray: [], + lockingModule: 'pos', + }, + ]; + + beforeEach(() => { + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + methodContext = createTransientMethodContext({ stateStore }); + }); + + describe('getNFTs', () => { + beforeEach(async () => { + for (const nft of nfts) { + await nftStore.save(methodContext, nft.id, { + owner, + attributesArray: nft.attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(owner, nft.id), { + lockingModule: nft.lockingModule, + }); + } + + await nftStore.save(methodContext, utils.getRandomBytes(LENGTH_NFT_ID), { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + }); + + it('should fail if address does not have valid length', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: 'incorrect', + }, + }); + + await expect(endpoint.getNFTs(context)).rejects.toThrow( + `'.address' must match format "lisk32"`, + ); + }); + + it('should return empty NFTs collection if owner has no NFTs', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: address.getLisk32AddressFromAddress(utils.getRandomBytes(LENGTH_ADDRESS)), + }, + }); + + await expect(endpoint.getNFTs(context)).resolves.toEqual({ nfts: [] }); + + validator.validate(getNFTsResponseSchema, { nfts: [] }); + }); + + it('should return NFTs for the provided owner lexicograhpically per id', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + }, + }); + + const expectedNFTs = { + nfts: nfts + .sort((a, b) => a.id.compare(b.id)) + .map(nft => ({ + id: nft.id.toString('hex'), + attributesArray: nft.attributesArray.map(attribute => ({ + module: attribute.module, + attributes: attribute.attributes.toString('hex'), + })), + lockingModule: nft.lockingModule, + })), + }; + + await expect(endpoint.getNFTs(context)).resolves.toEqual(expectedNFTs); + + validator.validate(getNFTsResponseSchema, expectedNFTs); + }); + }); + + describe('hasNFT', () => { + it('should fail if address is not valid', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: 'incorrect', + id: utils.getRandomBytes(LENGTH_NFT_ID).toString('hex'), + }, + }); + + await expect(endpoint.hasNFT(context)).rejects.toThrow( + `'.address' must match format "lisk32"`, + ); + }); + + it('should fail if id does not have valid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + id: utils.getRandomBytes(LENGTH_NFT_ID - 1).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + id: utils.getRandomBytes(LENGTH_NFT_ID + 1).toString('hex'), + }, + }); + + await expect(endpoint.hasNFT(minLengthContext)).rejects.toThrow( + `'.id' must NOT have fewer than 32 characters`, + ); + + await expect(endpoint.hasNFT(maxLengthContext)).rejects.toThrow( + `'.id' must NOT have more than 32 characters`, + ); + }); + + it('should return false if provided NFT does not exist', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + id: utils.getRandomBytes(LENGTH_NFT_ID).toString('hex'), + }, + }); + + await expect(endpoint.hasNFT(context)).resolves.toEqual({ hasNFT: false }); + + validator.validate(hasNFTResponseSchema, { hasNFT: false }); + }); + + it('should return false if provided NFT is not owned by the provided address', async () => { + await nftStore.save(methodContext, nfts[0].id, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + id: nfts[0].id.toString('hex'), + }, + }); + + await expect(endpoint.hasNFT(context)).resolves.toEqual({ hasNFT: false }); + + validator.validate(hasNFTResponseSchema, { hasNFT: false }); + }); + + it('should return true if provided is owned by the provided address', async () => { + await nftStore.save(methodContext, nfts[0].id, { + owner, + attributesArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + address: ownerAddress, + id: nfts[0].id.toString('hex'), + }, + }); + + await expect(endpoint.hasNFT(context)).resolves.toEqual({ hasNFT: true }); + + validator.validate(hasNFTResponseSchema, { hasNFT: true }); + }); + }); + + describe('getNFT', () => { + it('should fail if id does not have valid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + id: utils.getRandomBytes(LENGTH_NFT_ID - 1).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + id: utils.getRandomBytes(LENGTH_NFT_ID + 1).toString('hex'), + }, + }); + + await expect(endpoint.getNFT(minLengthContext)).rejects.toThrow( + `'.id' must NOT have fewer than 32 characters`, + ); + + await expect(endpoint.getNFT(maxLengthContext)).rejects.toThrow( + `'.id' must NOT have more than 32 characters`, + ); + }); + + it('should fail if NFT does not exist', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nfts[0].id.toString('hex'), + }, + }); + + await expect(endpoint.getNFT(context)).rejects.toThrow('NFT does not exist'); + }); + + it('should return NFT details', async () => { + const attributesArray = [ + { + module: 'pos', + attributes: utils.getRandomBytes(10), + }, + ]; + await nftStore.save(methodContext, nfts[0].id, { + owner, + attributesArray, + }); + + await userStore.set(methodContext, userStore.getKey(owner, nfts[0].id), { + lockingModule: NFT_NOT_LOCKED, + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nfts[0].id.toString('hex'), + }, + }); + + const expectedNFT: JSONObject = { + owner: owner.toString('hex'), + attributesArray: attributesArray.map(attribute => ({ + module: attribute.module, + attributes: attribute.attributes.toString('hex'), + })), + lockingModule: NFT_NOT_LOCKED, + }; + + await expect(endpoint.getNFT(context)).resolves.toEqual(expectedNFT); + + validator.validate(getNFTResponseSchema, expectedNFT); + }); + }); + + describe('getCollectionIDs', () => { + it('should fail if provided chainID has invalid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1).toString('hex'), + }, + }); + + await expect(endpoint.getCollectionIDs(minLengthContext)).rejects.toThrow( + `'.chainID' must NOT have fewer than 8 characters`, + ); + + await expect(endpoint.getCollectionIDs(maxLengthContext)).rejects.toThrow( + `'.chainID' must NOT have more than 8 characters`, + ); + }); + + it('should return empty list if provided chainID does not exist', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID).toString('hex'), + }, + }); + + await expect(endpoint.getCollectionIDs(context)).resolves.toEqual({ collectionIDs: [] }); + }); + + it('should return supported collections of the provided chain', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + const supportedCollections = [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + ]; + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: supportedCollections, + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: chainID.toString('hex'), + }, + }); + + const expectedSupportedCollection = { + collectionIDs: supportedCollections.map(collection => + collection.collectionID.toString('hex'), + ), + }; + + await expect(endpoint.getCollectionIDs(context)).resolves.toEqual( + expectedSupportedCollection, + ); + + validator.validate(getCollectionIDsResponseSchema, expectedSupportedCollection); + }); + }); + + describe('collectionExists', () => { + it('should fail if provided chainID has invalid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + await expect(endpoint.collectionExists(minLengthContext)).rejects.toThrow( + `'.chainID' must NOT have fewer than 8 characters`, + ); + + await expect(endpoint.collectionExists(maxLengthContext)).rejects.toThrow( + `'.chainID' must NOT have more than 8 characters`, + ); + }); + + it('should fail if provided collectionID has invalid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID - 1).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID + 1).toString('hex'), + }, + }); + + await expect(endpoint.collectionExists(minLengthContext)).rejects.toThrow( + `'.collectionID' must NOT have fewer than 8 characters`, + ); + + await expect(endpoint.collectionExists(maxLengthContext)).rejects.toThrow( + `'.collectionID' must NOT have more than 8 characters`, + ); + }); + + it('should return false if provided chainID does not exist', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + await expect(endpoint.collectionExists(context)).resolves.toEqual({ + collectionExists: false, + }); + + validator.validate(collectionExistsResponseSchema, { collectionExists: false }); + }); + + it('should return false if provided collectionID does not exist for the provided chainID', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + ], + }); + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: chainID.toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + await expect(endpoint.collectionExists(context)).resolves.toEqual({ + collectionExists: false, + }); + }); + + it('should return true if provided collectionID exists for the provided chainID', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_COLLECTION_ID); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID, + }, + ], + }); + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: chainID.toString('hex'), + collectionID: collectionID.toString('hex'), + }, + }); + + await expect(endpoint.collectionExists(context)).resolves.toEqual({ collectionExists: true }); + + validator.validate(collectionExistsResponseSchema, { collectionExists: true }); + }); + }); + + describe('getEscrowedNFTIDs', () => { + it('should fail if provided chainID has invalid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1).toString('hex'), + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID).toString('hex'), + }, + }); + + await expect(endpoint.getEscrowedNFTIDs(minLengthContext)).rejects.toThrow( + `'.chainID' must NOT have fewer than 8 characters`, + ); + + await expect(endpoint.getEscrowedNFTIDs(maxLengthContext)).rejects.toThrow( + `'.chainID' must NOT have more than 8 characters`, + ); + }); + + it('should return empty list if provided chain has no NFTs escrowed to it', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID).toString('hex'), + }, + }); + + await expect(endpoint.getEscrowedNFTIDs(context)).resolves.toEqual({ escrowedNFTIDs: [] }); + + validator.validate(getEscrowedNFTIDsResponseSchema, { escrowedNFTIDs: [] }); + }); + + it('should return list of escrowed NFTs for the chainID', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const nftIDs = [Buffer.alloc(LENGTH_NFT_ID, 0), Buffer.alloc(LENGTH_NFT_ID, 255)]; + + for (const nftID of nftIDs) { + await nftStore.save(methodContext, nftID, { + owner: chainID, + attributesArray: [], + }); + } + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + chainID: chainID.toString('hex'), + }, + }); + + const expectedNFTIDs = { escrowedNFTIDs: nftIDs.map(nftID => nftID.toString('hex')) }; + + await expect(endpoint.getEscrowedNFTIDs(context)).resolves.toEqual(expectedNFTIDs); + + validator.validate(getEscrowedNFTIDsResponseSchema, expectedNFTIDs); + }); + }); + + describe('isNFTSupported', () => { + it('should fail if id does not have valid length', async () => { + const minLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + id: utils.getRandomBytes(LENGTH_NFT_ID - 1).toString('hex'), + }, + }); + + const maxLengthContext = createTransientModuleEndpointContext({ + stateStore, + params: { + id: utils.getRandomBytes(LENGTH_NFT_ID + 1).toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(minLengthContext)).rejects.toThrow( + `'.id' must NOT have fewer than 32 characters`, + ); + + await expect(endpoint.isNFTSupported(maxLengthContext)).rejects.toThrow( + `'.id' must NOT have more than 32 characters`, + ); + }); + + it('should return false if NFT does not exist', async () => { + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: utils.getRandomBytes(LENGTH_NFT_ID).toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: false }); + + validator.validate(isNFTSupportedResponseSchema, { isNFTSupported: false }); + }); + + it('should return true if chainID of NFT is equal to ownChainID', async () => { + const nftID = Buffer.concat([ownChainID, Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nftID.toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: true }); + + validator.validate(isNFTSupportedResponseSchema, { isNFTSupported: true }); + }); + + it('should return true if all NFTs are supported', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await supportedNFTsStore.save(methodContext, ALL_SUPPORTED_NFTS_KEY, { + supportedCollectionIDArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nftID.toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: true }); + }); + + it('should return true if all collections of the chain are supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const nftID = Buffer.concat([chainID, Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID)]); + + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nftID.toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: true }); + }); + + it('should return true if collection of the chain is supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_COLLECTION_ID); + const nftID = Buffer.concat([ + chainID, + collectionID, + Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID), + ]); + + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID, + }, + ], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nftID.toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: true }); + }); + + it('should return false if collection of the chain is not supported', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const collectionID = utils.getRandomBytes(LENGTH_COLLECTION_ID); + const nftID = Buffer.concat([ + chainID, + collectionID, + Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID), + ]); + + await nftStore.save(methodContext, nftID, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await supportedNFTsStore.save(methodContext, chainID, { + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + ], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nftID.toString('hex'), + }, + }); + + await expect(endpoint.isNFTSupported(context)).resolves.toEqual({ isNFTSupported: false }); + }); + }); +}); From 993411e9975fac192186030d5d9b4a4885d73f0e Mon Sep 17 00:00:00 2001 From: has5aan Date: Mon, 19 Jun 2023 23:13:24 +0200 Subject: [PATCH 28/58] :seedling: NFTModule.initGenesisState --- framework/src/modules/nft/module.ts | 154 +++- framework/src/modules/nft/schemas.ts | 132 ++++ framework/src/modules/nft/types.ts | 26 + .../nft/init_genesis_state_fixtures.ts | 381 +++++++++ .../test/unit/modules/nft/module.spec.ts | 740 +++++++++++++++++- 5 files changed, 1428 insertions(+), 5 deletions(-) create mode 100644 framework/test/unit/modules/nft/init_genesis_state_fixtures.ts diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 9dce479a4ec..ca3fc41e111 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -12,6 +12,9 @@ * Removal or modification of this copyright notice is prohibited. */ +import { dataStructures } from '@liskhq/lisk-utils'; +import { codec } from '@liskhq/lisk-codec'; +import { validator } from '@liskhq/lisk-validator'; import { GenesisBlockExecuteContext } from '../../state_machine'; import { ModuleInitArgs, ModuleMetadata } from '../base_module'; import { BaseInteroperableModule } from '../interoperability'; @@ -50,15 +53,17 @@ import { hasNFTResponseSchema, isNFTSupportedRequestSchema, isNFTSupportedResponseSchema, + genesisNFTStoreSchema, } from './schemas'; import { EscrowStore } from './stores/escrow'; import { NFTStore } from './stores/nft'; import { SupportedNFTsStore } from './stores/supported_nfts'; import { UserStore } from './stores/user'; -import { FeeMethod, TokenMethod } from './types'; +import { FeeMethod, GenesisNFTStore, TokenMethod } from './types'; import { CrossChainTransferCommand as CrossChainTransferMessageCommand } from './cc_commands/cc_transfer'; import { TransferCrossChainCommand } from './commands/transfer_cross_chain'; import { TransferCommand } from './commands/transfer'; +import { ALL_SUPPORTED_NFTS_KEY, LENGTH_ADDRESS, LENGTH_CHAIN_ID } from './constants'; export class NFTModule extends BaseInteroperableModule { public method = new NFTMethod(this.stores, this.events); @@ -174,6 +179,149 @@ export class NFTModule extends BaseInteroperableModule { // eslint-disable-next-line @typescript-eslint/no-empty-function public async init(_args: ModuleInitArgs) {} - // eslint-disable-next-line @typescript-eslint/no-empty-function - public async initGenesisState(_context: GenesisBlockExecuteContext): Promise {} + public async initGenesisState(context: GenesisBlockExecuteContext): Promise { + const assetBytes = context.assets.getAsset(this.name); + + if (!assetBytes) { + return; + } + + const genesisStore = codec.decode(genesisNFTStoreSchema, assetBytes); + validator.validate(genesisNFTStoreSchema, genesisStore); + + const nftIDKeySet = new dataStructures.BufferSet(); + + for (const nft of genesisStore.nftSubstore) { + if (![LENGTH_CHAIN_ID, LENGTH_ADDRESS].includes(nft.owner.length)) { + throw new Error(`nftID ${nft.nftID.toString('hex')} has invalid owner`); + } + if (nftIDKeySet.has(nft.nftID)) { + throw new Error(`nftID ${nft.nftID.toString('hex')} duplicated`); + } + + nftIDKeySet.add(nft.nftID); + } + + for (const nft of genesisStore.nftSubstore) { + const ownerUsers = genesisStore.userSubstore.filter(userEntry => + userEntry.nftID.equals(nft.nftID), + ); + const ownerChains = genesisStore.escrowSubstore.filter(escrowEntry => + escrowEntry.nftID.equals(nft.nftID), + ); + + if (ownerUsers.length === 0 && ownerChains.length === 0) { + throw new Error( + `nftID ${nft.nftID.toString( + 'hex', + )} has no corresponding entry for UserSubstore or EscrowSubstore`, + ); + } + + if (ownerUsers.length > 0 && ownerChains.length > 0) { + throw new Error( + `nftID ${nft.nftID.toString( + 'hex', + )} has an entry for both UserSubstore and EscrowSubstore`, + ); + } + + if (ownerUsers.length > 1) { + throw new Error(`nftID ${nft.nftID.toString('hex')} has multiple entries for UserSubstore`); + } + + if (ownerChains.length > 1) { + throw new Error( + `nftID ${nft.nftID.toString('hex')} has multiple entries for EscrowSubstore`, + ); + } + + if (nft.owner.length === LENGTH_CHAIN_ID && ownerChains.length !== 1) { + throw new Error( + `nftID ${nft.nftID.toString( + 'hex', + )} should have a corresponding entry for EscrowSubstore only`, + ); + } + + const attributeSet: Record = {}; + + for (const attribute of nft.attributesArray) { + attributeSet[attribute.module] = (attributeSet[attribute.module] ?? 0) + 1; + + if (attributeSet[attribute.module] > 1) { + throw new Error( + `nftID ${nft.nftID.toString('hex')} has a duplicate attribute for ${ + attribute.module + } module`, + ); + } + } + } + + if (genesisStore.supportedNFTsSubstore.length === 0) { + return; + } + + const allNFTsSupported = genesisStore.supportedNFTsSubstore.some(supportedNFTs => + supportedNFTs.chainID.equals(ALL_SUPPORTED_NFTS_KEY), + ); + + if (genesisStore.supportedNFTsSubstore.length > 1 && allNFTsSupported) { + throw new Error( + 'SupportedNFTsSubstore should contain only one entry if all NFTs are supported', + ); + } + + if ( + allNFTsSupported && + genesisStore.supportedNFTsSubstore[0].supportedCollectionIDArray.length !== 0 + ) { + throw new Error('supportedCollectionIDArray must be empty if all NFTs are supported'); + } + + const supportedChainsKeySet = new dataStructures.BufferSet(); + for (const supportedNFT of genesisStore.supportedNFTsSubstore) { + if (supportedChainsKeySet.has(supportedNFT.chainID)) { + throw new Error(`chainID ${supportedNFT.chainID.toString('hex')} duplicated`); + } + + supportedChainsKeySet.add(supportedNFT.chainID); + } + + const nftStore = this.stores.get(NFTStore); + for (const nft of genesisStore.nftSubstore) { + const { nftID, owner, attributesArray } = nft; + + await nftStore.save(context, nftID, { + owner, + attributesArray, + }); + } + + const userStore = this.stores.get(UserStore); + for (const user of genesisStore.userSubstore) { + const { address, nftID, lockingModule } = user; + + await userStore.set(context, userStore.getKey(address, nftID), { + lockingModule, + }); + } + + const escrowStore = this.stores.get(EscrowStore); + for (const escrow of genesisStore.escrowSubstore) { + const { escrowedChainID, nftID } = escrow; + + await escrowStore.set(context, escrowStore.getKey(escrowedChainID, nftID), {}); + } + + for (const supportedNFT of genesisStore.supportedNFTsSubstore) { + const { chainID, supportedCollectionIDArray } = supportedNFT; + const supportedNFTsSubstore = this.stores.get(SupportedNFTsStore); + + await supportedNFTsSubstore.save(context, chainID, { + supportedCollectionIDArray, + }); + } + } } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index da8f393bc6c..f35b837f389 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -20,6 +20,7 @@ import { MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME, MAX_LENGTH_DATA, + LENGTH_ADDRESS, } from './constants'; export const transferParamsSchema = { @@ -387,3 +388,134 @@ export const isNFTSupportedResponseSchema = { }, }, }; + +export const genesisNFTStoreSchema = { + $id: '/nft/module/genesis', + type: 'object', + required: ['nftSubstore', 'userSubstore', 'escrowSubstore', 'supportedNFTsSubstore'], + properties: { + nftSubstore: { + type: 'array', + fieldNumber: 1, + items: { + type: 'object', + required: ['nftID', 'owner', 'attributesArray'], + properties: { + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + owner: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_ADDRESS, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 3, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, + }, + }, + userSubstore: { + type: 'array', + fieldNumber: 2, + items: { + type: 'object', + required: ['address', 'nftID', 'lockingModule'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + lockingModule: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 3, + }, + }, + }, + }, + escrowSubstore: { + type: 'array', + fieldNumber: 3, + items: { + type: 'object', + required: ['escrowedChainID', 'nftID'], + properties: { + escrowedChainID: { + dataType: 'bytes', + minLength: LENGTH_CHAIN_ID, + maxLength: LENGTH_CHAIN_ID, + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + }, + }, + }, + supportedNFTsSubstore: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['chainID', 'supportedCollectionIDArray'], + properties: { + chainID: { + dataType: 'bytes', + fieldNumber: 1, + }, + supportedCollectionIDArray: { + type: 'array', + fieldNumber: 2, + items: { + type: 'object', + required: ['collectionID'], + properties: { + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 1, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index bfa88ef2189..1438bc4daef 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -64,3 +64,29 @@ export interface NFT { attributesArray: NFTAttributes[]; lockingModule: string; } + +export interface GenesisNFTStore { + nftSubstore: { + nftID: Buffer; + owner: Buffer; + attributesArray: { + module: string; + attributes: Buffer; + }[]; + }[]; + userSubstore: { + address: Buffer; + nftID: Buffer; + lockingModule: string; + }[]; + escrowSubstore: { + escrowedChainID: Buffer; + nftID: Buffer; + }[]; + supportedNFTsSubstore: { + chainID: Buffer; + supportedCollectionIDArray: { + collectionID: Buffer; + }[]; + }[]; +} diff --git a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts new file mode 100644 index 00000000000..eadffcd2bdf --- /dev/null +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -0,0 +1,381 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { GenesisNFTStore } from '../../../../src/modules/nft/types'; +import { + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, +} from '../../../../src/modules/nft/constants'; + +const nftID1 = utils.getRandomBytes(LENGTH_NFT_ID); +const nftID2 = utils.getRandomBytes(LENGTH_NFT_ID); +const owner = utils.getRandomBytes(LENGTH_ADDRESS); +const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + +export const validData: GenesisNFTStore = { + nftSubstore: [ + { + nftID: nftID1, + owner, + attributesArray: [ + { + module: 'pos', + attributes: utils.getRandomBytes(10), + }, + { + module: 'token', + attributes: utils.getRandomBytes(10), + }, + ], + }, + { + nftID: nftID2, + owner, + attributesArray: [ + { + module: 'pos', + attributes: utils.getRandomBytes(10), + }, + { + module: 'token', + attributes: utils.getRandomBytes(10), + }, + ], + }, + ], + userSubstore: [ + { + address: owner, + nftID: nftID1, + lockingModule: 'pos', + }, + { + address: owner, + nftID: nftID2, + lockingModule: 'token', + }, + ], + escrowSubstore: [ + { + escrowedChainID, + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }, + ], + supportedNFTsSubstore: [ + { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + supportedCollectionIDArray: [], + }, + ], +}; + +export const validGenesisAssets = [['Valid genesis asset', validData]]; + +export const invalidSchemaNFTSubstoreGenesisAssets = [ + [ + 'Invalid nftID - minimum length not satisfied', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID - 1), + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + }, + `nftID' minLength not satisfied`, + ], + [ + 'Invalid nftID - maximum length exceeded', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + }, + `nftID' maxLength exceeded`, + ], + [ + 'Invalid owner - minimum length not satisfied', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + owner: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), + attributesArray: [], + }, + ], + }, + `owner' minLength not satisfied`, + ], + [ + 'Invalid owner - maximum length exceeded', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + owner: utils.getRandomBytes(LENGTH_ADDRESS + 1), + attributesArray: [], + }, + ], + }, + `owner' maxLength exceeded`, + ], + [ + 'Invalid attributesArray.module - minimum length not satisfied', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: '', + attributes: utils.getRandomBytes(10), + }, + ], + }, + ], + }, + `module' must NOT have fewer than 1 characters`, + ], + [ + 'Invalid attributesArray.module - maximum length exceeded', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: '1'.repeat(33), + attributes: utils.getRandomBytes(10), + }, + ], + }, + ], + }, + `module' must NOT have more than 32 characters`, + ], + [ + 'Invalid attributesArray.module - must match pattern "^[a-zA-Z0-9]*$"', + { + ...validData, + nftSubstore: [ + { + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: '#$a1!', + attributes: utils.getRandomBytes(10), + }, + ], + }, + ], + }, + 'must match pattern "^[a-zA-Z0-9]*$"', + ], +]; + +export const invalidSchemaUserSubstoreGenesisAssests = [ + [ + 'Invalid owner address', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS - 1), + nftID: nftID1, + lockingModule: 'pos', + }, + ], + }, + `address' address length invalid`, + ], + [ + 'Invalid nftID - minimum length not satisified', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID - 1), + lockingModule: 'pos', + }, + ], + }, + `nftID' minLength not satisfied`, + ], + [ + 'Invalid nftID - maximum length exceeded', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), + lockingModule: 'pos', + }, + ], + }, + `nftID' maxLength exceeded`, + ], + [ + 'Invalid lockingModule - minimum length not satisfied', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + lockingModule: '', + }, + ], + }, + `lockingModule' must NOT have fewer than 1 characters`, + ], + [ + 'Invalid lockingModule - maximum length exceeded', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + lockingModule: 'pos'.repeat(33), + }, + ], + }, + `lockingModule' must NOT have more than 32 characters`, + ], + [ + 'lockingModule must match pattern - "^[a-zA-Z0-9]*$"', + { + ...validData, + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + lockingModule: '$#pos"', + }, + ], + }, + `must match pattern "^[a-zA-Z0-9]*$"`, + ], +]; + +export const invalidSchemaEscrowSubstoreGenesisAssets = [ + [ + 'Invalid escrowedChainID - minimum length not satisfied', + { + ...validData, + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), + nftID: nftID1, + }, + ], + }, + `escrowedChainID' minLength not satisfied`, + ], + [ + 'Invalid escrowedChainID - maximum length exceeded', + { + ...validData, + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1), + nftID: nftID1, + }, + ], + }, + `escrowedChainID' maxLength exceeded`, + ], + [ + 'Invalid nftID - minimum length not satisfied', + { + ...validData, + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), + }, + ], + }, + `nftID' minLength not satisfied`, + ], + [ + 'Invalid nftID - maximum length exceeded', + { + ...validData, + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), + }, + ], + }, + `nftID' maxLength exceeded`, + ], +]; + +export const invalidSchemaSupportedNFTsSubstoreGenesisAssets = [ + [ + 'Invalid collectionID - minimum length not satisfied', + { + ...validData, + supportedNFTsSubstore: [ + { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID - 1), + }, + ], + }, + ], + }, + `collectionID' minLength not satisfied`, + ], + [ + 'Invalid collectionID - maximum length exceeded', + { + ...validData, + supportedNFTsSubstore: [ + { + chainID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID + 1), + }, + ], + }, + ], + }, + `collectionID' maxLength exceeded`, + ], +]; diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts index ccbc53346ba..51827723523 100644 --- a/framework/test/unit/modules/nft/module.spec.ts +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -11,8 +11,744 @@ * * Removal or modification of this copyright notice is prohibited. */ + +import { utils } from '@liskhq/lisk-cryptography'; +import { codec } from '@liskhq/lisk-codec'; +import { BlockAssets } from '@liskhq/lisk-chain'; +import { NFTModule } from '../../../../src/modules/nft/module'; +import { createGenesisBlockContext } from '../../../../src/testing'; +import { + invalidSchemaEscrowSubstoreGenesisAssets, + invalidSchemaNFTSubstoreGenesisAssets, + invalidSchemaSupportedNFTsSubstoreGenesisAssets, + invalidSchemaUserSubstoreGenesisAssests, + validData, +} from './init_genesis_state_fixtures'; +import { genesisNFTStoreSchema } from '../../../../src/modules/nft/schemas'; +import { + ALL_SUPPORTED_NFTS_KEY, + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, +} from '../../../../src/modules/nft/constants'; +import { NFTStore } from '../../../../src/modules/nft/stores/nft'; +import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; +import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; + describe('nft module', () => { - it('should be implemented', () => { - expect(true).toBeTrue(); + const module = new NFTModule(); + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + const supportedNFTsSubstore = module.stores.get(SupportedNFTsStore); + const escrowStore = module.stores.get(EscrowStore); + + const createGenesisBlockContextFromGenesisAssets = (genesisAssets: object) => { + const encodedAsset = codec.encode(genesisNFTStoreSchema, genesisAssets); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + return context; + }; + + describe('iniGenesisState', () => { + describe('validate nftSubstore schema', () => { + it.each(invalidSchemaNFTSubstoreGenesisAssets)('%s', async (_, input, err) => { + if (typeof input === 'string') { + return; + } + + const encodedAsset = codec.encode(genesisNFTStoreSchema, input); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + await expect(module.initGenesisState(context)).rejects.toThrow(err as string); + }); + }); + + describe('validate userSubstore schema', () => { + it.each(invalidSchemaUserSubstoreGenesisAssests)('%s', async (_, input, err) => { + if (typeof input === 'string') { + return; + } + + const encodedAsset = codec.encode(genesisNFTStoreSchema, input); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + await expect(module.initGenesisState(context)).rejects.toThrow(err as string); + }); + }); + + describe('validate escrowSubstore schema', () => { + it.each(invalidSchemaEscrowSubstoreGenesisAssets)('%s', async (_, input, err) => { + if (typeof input === 'string') { + return; + } + + const encodedAsset = codec.encode(genesisNFTStoreSchema, input); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + await expect(module.initGenesisState(context)).rejects.toThrow(err as string); + }); + }); + + describe('validate supportedNFTsSubstore schema', () => { + it.each(invalidSchemaSupportedNFTsSubstoreGenesisAssets)('%s', async (_, input, err) => { + if (typeof input === 'string') { + return; + } + + const encodedAsset = codec.encode(genesisNFTStoreSchema, input); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + await expect(module.initGenesisState(context)).rejects.toThrow(err as string); + }); + }); + + it('should throw if owner of the NFT is not a valid address', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS - 1), + attributesArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has invalid owner`, + ); + }); + + it('should throw if owner of the NFT is not a valid chain', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_CHAIN_ID + 1), + attributesArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has invalid owner`, + ); + }); + + it('should throw if nftID is duplicated', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} duplicated`, + ); + }); + + it('should throw if NFT does not have a corresponding entry for user or escrow store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString( + 'hex', + )} has no corresponding entry for UserSubstore or EscrowSubstore`, + ); + }); + + it('should throw if NFT has an entry for both user and escrow store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'pos', + }, + ], + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID, + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has an entry for both UserSubstore and EscrowSubstore`, + ); + }); + + it('should throw if NFT has multiple entries for user store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'pos', + }, + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'token', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has multiple entries for UserSubstore`, + ); + }); + + it('should throw if NFT has multiple entries for escrow store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + escrowSubstore: [ + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID, + }, + { + escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + nftID, + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has multiple entries for EscrowSubstore`, + ); + }); + + it('should throw if escrowed NFT has no corresponding entry for escrow store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'pos', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} should have a corresponding entry for EscrowSubstore only`, + ); + }); + + it('should throw if NFT has duplicate attribute for an array', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const moduleName = 'pos'; + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: moduleName, + attributes: Buffer.alloc(10), + }, + { + module: moduleName, + attributes: Buffer.alloc(0), + }, + ], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'pos', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${nftID.toString('hex')} has a duplicate attribute for pos module`, + ); + }); + + it('should throw if all NFTs are supported and SupportedNFTsSubstore contains more than one entry', async () => { + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID: Buffer.alloc(0), + supportedCollectionIDArray: [], + }, + { + chainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + supportedCollectionIDArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + 'SupportedNFTsSubstore should contain only one entry if all NFTs are supported', + ); + }); + + it('should throw if all NFTs are supported and supportedCollectionIDArray is not empty', async () => { + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID: ALL_SUPPORTED_NFTS_KEY, + supportedCollectionIDArray: [ + { + collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID), + }, + ], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + 'supportedCollectionIDArray must be empty if all NFTs are supported', + ); + }); + + it('should throw if supported chain is duplicated', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID, + supportedCollectionIDArray: [], + }, + { + chainID, + supportedCollectionIDArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `chainID ${chainID.toString('hex')} duplicated`, + ); + }); + + it('should create entries for all NFTs lexicographically', async () => { + const nftID1 = Buffer.alloc(LENGTH_NFT_ID, 1); + const nftID2 = Buffer.alloc(LENGTH_NFT_ID, 0); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID: nftID1, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + { + nftID: nftID2, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: nftID1, + lockingModule: 'pos', + }, + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: nftID2, + lockingModule: 'pos', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const allNFTs = await nftStore.iterate(context.getMethodContext(), { + gte: Buffer.alloc(LENGTH_NFT_ID, 0), + lte: Buffer.alloc(LENGTH_NFT_ID, 255), + }); + + const expectedKeys = [nftID2, nftID1]; + + expect(expectedKeys).toEqual(allNFTs.map(nft => nft.key)); + }); + + it('should create entry for an NFT with attributesArray sorted lexicographically on module', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: 'token', + attributes: utils.getRandomBytes(10), + }, + { + module: 'pos', + attributes: utils.getRandomBytes(10), + }, + ], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'pos', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const nft = await nftStore.get(context.getMethodContext(), nftID); + + expect(nft.attributesArray.map(attribute => attribute.module)).toEqual(['pos', 'token']); + }); + + it('should remove entries in attributes array with empty attributes', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [ + { + module: 'token', + attributes: Buffer.alloc(0), + }, + ], + }, + ], + userSubstore: [ + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID, + lockingModule: 'token', + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const nft = await nftStore.get(context.getMethodContext(), nftID); + + expect(nft.attributesArray).toHaveLength(0); + }); + + it('should create an entry for ALL_SUPPORTED_NFTS_KEY with empty supportedCollectionIDArray if all NFTs are supported', async () => { + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID: ALL_SUPPORTED_NFTS_KEY, + supportedCollectionIDArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const supportedNFTs = await supportedNFTsSubstore.get( + context.getMethodContext(), + ALL_SUPPORTED_NFTS_KEY, + ); + + expect(supportedNFTs.supportedCollectionIDArray).toHaveLength(0); + }); + + it('should create entries for supported chains lexicographically', async () => { + const chainID1 = Buffer.alloc(LENGTH_CHAIN_ID, 1); + const chainID2 = Buffer.alloc(LENGTH_CHAIN_ID, 0); + + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID: chainID1, + supportedCollectionIDArray: [], + }, + { + chainID: chainID2, + supportedCollectionIDArray: [], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const allSupportedNFTs = await supportedNFTsSubstore.getAll(context.getMethodContext()); + + const expectedKeys = [chainID2, chainID1]; + + expect(expectedKeys).toEqual(allSupportedNFTs.map(supportedNFTs => supportedNFTs.key)); + }); + + it('should create entries for user and escrow store', async () => { + const nftID1 = utils.getRandomBytes(LENGTH_NFT_ID); + const nftID2 = utils.getRandomBytes(LENGTH_NFT_ID); + const nftID3 = utils.getRandomBytes(LENGTH_NFT_ID); + + const escrowedNFTID1 = utils.getRandomBytes(LENGTH_NFT_ID); + const escrowedNFTID2 = utils.getRandomBytes(LENGTH_NFT_ID); + + const owner1 = utils.getRandomBytes(LENGTH_ADDRESS); + const owner2 = utils.getRandomBytes(LENGTH_ADDRESS); + + const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID: nftID1, + owner: owner1, + attributesArray: [], + }, + { + nftID: nftID2, + owner: owner1, + attributesArray: [], + }, + { + nftID: nftID3, + owner: owner2, + attributesArray: [], + }, + { + nftID: escrowedNFTID1, + owner: escrowedChainID, + attributesArray: [], + }, + ], + userSubstore: [ + { + address: owner1, + nftID: nftID1, + lockingModule: 'pos', + }, + { + address: owner1, + nftID: nftID2, + lockingModule: 'token', + }, + { + address: owner2, + nftID: nftID3, + lockingModule: 'auth', + }, + ], + escrowSubstore: [ + { + escrowedChainID, + nftID: escrowedNFTID1, + }, + { + escrowedChainID, + nftID: escrowedNFTID2, + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + await expect( + userStore.get(context.getMethodContext(), userStore.getKey(owner1, nftID1)), + ).resolves.toEqual({ lockingModule: 'pos' }); + + await expect( + userStore.get(context.getMethodContext(), userStore.getKey(owner1, nftID2)), + ).resolves.toEqual({ lockingModule: 'token' }); + + await expect( + userStore.get(context.getMethodContext(), userStore.getKey(owner2, nftID3)), + ).resolves.toEqual({ lockingModule: 'auth' }); + + await expect( + escrowStore.get( + context.getMethodContext(), + escrowStore.getKey(escrowedChainID, escrowedNFTID1), + ), + ).resolves.toEqual({}); + + await expect( + escrowStore.get( + context.getMethodContext(), + escrowStore.getKey(escrowedChainID, escrowedNFTID2), + ), + ).resolves.toEqual({}); + }); + + it('should create an entry for supported chains with supportedCollectionIDArray sorted lexicographically', async () => { + const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + const collectionID1 = Buffer.alloc(LENGTH_COLLECTION_ID, 1); + const collectionID2 = Buffer.alloc(LENGTH_COLLECTION_ID, 0); + + const genesisAssets = { + ...validData, + supportedNFTsSubstore: [ + { + chainID, + supportedCollectionIDArray: [ + { + collectionID: collectionID1, + }, + { + collectionID: collectionID2, + }, + ], + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + const supportedNFT = await supportedNFTsSubstore.get(context.getMethodContext(), chainID); + + expect(supportedNFT.supportedCollectionIDArray).toEqual([ + { + collectionID: collectionID2, + }, + { + collectionID: collectionID1, + }, + ]); + }); }); }); From 13e3c271bcc048fb2dfec663f5c4f47b29360c79 Mon Sep 17 00:00:00 2001 From: has5aan Date: Tue, 20 Jun 2023 13:08:31 +0200 Subject: [PATCH 29/58] :recycle: Removes redundant verification for NFTs owner --- framework/src/modules/nft/schemas.ts | 3 -- .../nft/init_genesis_state_fixtures.ts | 28 ------------------- 2 files changed, 31 deletions(-) diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index f35b837f389..dbe020974a6 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -20,7 +20,6 @@ import { MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME, MAX_LENGTH_DATA, - LENGTH_ADDRESS, } from './constants'; export const transferParamsSchema = { @@ -409,8 +408,6 @@ export const genesisNFTStoreSchema = { }, owner: { dataType: 'bytes', - minLength: LENGTH_CHAIN_ID, - maxLength: LENGTH_ADDRESS, fieldNumber: 2, }, attributesArray: { diff --git a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts index eadffcd2bdf..1ae16bd1efc 100644 --- a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -114,34 +114,6 @@ export const invalidSchemaNFTSubstoreGenesisAssets = [ }, `nftID' maxLength exceeded`, ], - [ - 'Invalid owner - minimum length not satisfied', - { - ...validData, - nftSubstore: [ - { - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - owner: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), - attributesArray: [], - }, - ], - }, - `owner' minLength not satisfied`, - ], - [ - 'Invalid owner - maximum length exceeded', - { - ...validData, - nftSubstore: [ - { - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - owner: utils.getRandomBytes(LENGTH_ADDRESS + 1), - attributesArray: [], - }, - ], - }, - `owner' maxLength exceeded`, - ], [ 'Invalid attributesArray.module - minimum length not satisfied', { From ef4b00d02c03006caf0a4432eee8cd327c0d8d56 Mon Sep 17 00:00:00 2001 From: has5aan Date: Tue, 20 Jun 2023 23:19:42 +0200 Subject: [PATCH 30/58] :bug: :white_check_mark: Fixes setup code --- .../test/unit/modules/nft/init_genesis_state_fixtures.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts index 1ae16bd1efc..b8722c12a83 100644 --- a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -23,6 +23,7 @@ import { const nftID1 = utils.getRandomBytes(LENGTH_NFT_ID); const nftID2 = utils.getRandomBytes(LENGTH_NFT_ID); +const nftID3 = utils.getRandomBytes(LENGTH_NFT_ID); const owner = utils.getRandomBytes(LENGTH_ADDRESS); const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); @@ -56,6 +57,11 @@ export const validData: GenesisNFTStore = { }, ], }, + { + nftID: nftID3, + owner: escrowedChainID, + attributesArray: [], + }, ], userSubstore: [ { @@ -72,7 +78,7 @@ export const validData: GenesisNFTStore = { escrowSubstore: [ { escrowedChainID, - nftID: utils.getRandomBytes(LENGTH_NFT_ID), + nftID: nftID3, }, ], supportedNFTsSubstore: [ From 978fde0cee07cac3fb756630700d0044633737e6 Mon Sep 17 00:00:00 2001 From: has5aan Date: Tue, 20 Jun 2023 23:25:04 +0200 Subject: [PATCH 31/58] :pencil2: --- .../test/unit/modules/nft/init_genesis_state_fixtures.ts | 2 +- framework/test/unit/modules/nft/module.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts index b8722c12a83..6684b639c65 100644 --- a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -179,7 +179,7 @@ export const invalidSchemaNFTSubstoreGenesisAssets = [ ], ]; -export const invalidSchemaUserSubstoreGenesisAssests = [ +export const invalidSchemaUserSubstoreGenesisAssets = [ [ 'Invalid owner address', { diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts index 51827723523..0ed79df983b 100644 --- a/framework/test/unit/modules/nft/module.spec.ts +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -21,7 +21,7 @@ import { invalidSchemaEscrowSubstoreGenesisAssets, invalidSchemaNFTSubstoreGenesisAssets, invalidSchemaSupportedNFTsSubstoreGenesisAssets, - invalidSchemaUserSubstoreGenesisAssests, + invalidSchemaUserSubstoreGenesisAssets, validData, } from './init_genesis_state_fixtures'; import { genesisNFTStoreSchema } from '../../../../src/modules/nft/schemas'; @@ -55,7 +55,7 @@ describe('nft module', () => { return context; }; - describe('iniGenesisState', () => { + describe('initGenesisState', () => { describe('validate nftSubstore schema', () => { it.each(invalidSchemaNFTSubstoreGenesisAssets)('%s', async (_, input, err) => { if (typeof input === 'string') { @@ -73,7 +73,7 @@ describe('nft module', () => { }); describe('validate userSubstore schema', () => { - it.each(invalidSchemaUserSubstoreGenesisAssests)('%s', async (_, input, err) => { + it.each(invalidSchemaUserSubstoreGenesisAssets)('%s', async (_, input, err) => { if (typeof input === 'string') { return; } From ff248939b4b127230cc70a756e19d9f0eb56af35 Mon Sep 17 00:00:00 2001 From: has5aan Date: Wed, 21 Jun 2023 00:30:33 +0200 Subject: [PATCH 32/58] :bug: Removes check to return if supportedNFTsSubstore is empty --- framework/src/modules/nft/module.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index ca3fc41e111..b6288628f85 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -259,10 +259,6 @@ export class NFTModule extends BaseInteroperableModule { } } - if (genesisStore.supportedNFTsSubstore.length === 0) { - return; - } - const allNFTsSupported = genesisStore.supportedNFTsSubstore.some(supportedNFTs => supportedNFTs.chainID.equals(ALL_SUPPORTED_NFTS_KEY), ); From e6b43b3b4f710828f059155d0e7dab7a1d2b3600 Mon Sep 17 00:00:00 2001 From: has5aan Date: Wed, 21 Jun 2023 13:34:03 +0200 Subject: [PATCH 33/58] :bug: Adds NFT owner check to verify duplicate entries for an NFT in UserSubstore and EscrowSubstore and adds checks to throw if UserSubstore and EscrowSubstore has additional entries for an NFT not contained in NFTSubstore --- framework/src/modules/nft/module.ts | 40 ++++++- .../test/unit/modules/nft/module.spec.ts | 102 ++++++++++++++++-- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index b6288628f85..55719225028 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -203,14 +203,15 @@ export class NFTModule extends BaseInteroperableModule { } for (const nft of genesisStore.nftSubstore) { - const ownerUsers = genesisStore.userSubstore.filter(userEntry => - userEntry.nftID.equals(nft.nftID), + const userStoreEntries = genesisStore.userSubstore.filter(userStoreEntry => + userStoreEntry.nftID.equals(nft.nftID), ); - const ownerChains = genesisStore.escrowSubstore.filter(escrowEntry => + + const escrowStoreEntries = genesisStore.escrowSubstore.filter(escrowEntry => escrowEntry.nftID.equals(nft.nftID), ); - if (ownerUsers.length === 0 && ownerChains.length === 0) { + if (userStoreEntries.length === 0 && escrowStoreEntries.length === 0) { throw new Error( `nftID ${nft.nftID.toString( 'hex', @@ -218,7 +219,7 @@ export class NFTModule extends BaseInteroperableModule { ); } - if (ownerUsers.length > 0 && ownerChains.length > 0) { + if (userStoreEntries.length > 0 && escrowStoreEntries.length > 0) { throw new Error( `nftID ${nft.nftID.toString( 'hex', @@ -226,6 +227,15 @@ export class NFTModule extends BaseInteroperableModule { ); } + const ownerUsers = genesisStore.userSubstore.filter( + userEntry => userEntry.nftID.equals(nft.nftID) && userEntry.address.equals(nft.owner), + ); + + const ownerChains = genesisStore.escrowSubstore.filter( + escrowEntry => + escrowEntry.nftID.equals(nft.nftID) && escrowEntry.escrowedChainID.equals(nft.owner), + ); + if (ownerUsers.length > 1) { throw new Error(`nftID ${nft.nftID.toString('hex')} has multiple entries for UserSubstore`); } @@ -259,6 +269,26 @@ export class NFTModule extends BaseInteroperableModule { } } + for (const user of genesisStore.userSubstore) { + if (!genesisStore.nftSubstore.some(nft => nft.nftID.equals(user.nftID))) { + throw new Error( + `nftID ${user.nftID.toString( + 'hex', + )} in UserSubstore has no corresponding entry for NFTSubstore`, + ); + } + } + + for (const escrow of genesisStore.escrowSubstore) { + if (!genesisStore.nftSubstore.some(nft => nft.nftID.equals(escrow.nftID))) { + throw new Error( + `nftID ${escrow.nftID.toString( + 'hex', + )} in EscrowSubstore has no corresponding entry for NFTSubstore`, + ); + } + } + const allNFTsSupported = genesisStore.supportedNFTsSubstore.some(supportedNFTs => supportedNFTs.chainID.equals(ALL_SUPPORTED_NFTS_KEY), ); diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts index 0ed79df983b..c244432f8f9 100644 --- a/framework/test/unit/modules/nft/module.spec.ts +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -213,6 +213,7 @@ describe('nft module', () => { it('should throw if NFT has an entry for both user and escrow store', async () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + // const owner = utils.getRandomBytes(LENGTH_ADDRESS); const genesisAssets = { ...validData, @@ -247,28 +248,30 @@ describe('nft module', () => { it('should throw if NFT has multiple entries for user store', async () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const owner = utils.getRandomBytes(LENGTH_ADDRESS); const genesisAssets = { ...validData, nftSubstore: [ { nftID, - owner: utils.getRandomBytes(LENGTH_ADDRESS), + owner, attributesArray: [], }, ], userSubstore: [ { - address: utils.getRandomBytes(LENGTH_ADDRESS), + address: owner, nftID, lockingModule: 'pos', }, { - address: utils.getRandomBytes(LENGTH_ADDRESS), + address: owner, nftID, lockingModule: 'token', }, ], + escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -280,23 +283,25 @@ describe('nft module', () => { it('should throw if NFT has multiple entries for escrow store', async () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); const genesisAssets = { ...validData, nftSubstore: [ { nftID, - owner: utils.getRandomBytes(LENGTH_ADDRESS), + owner: escrowedChainID, attributesArray: [], }, ], + userSubstore: [], escrowSubstore: [ { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowedChainID, nftID, }, { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowedChainID, nftID, }, ], @@ -328,6 +333,7 @@ describe('nft module', () => { lockingModule: 'pos', }, ], + escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -375,6 +381,82 @@ describe('nft module', () => { ); }); + it('should throw if an NFT in user store has no corresponding entry for nft store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const owner = utils.getRandomBytes(LENGTH_ADDRESS); + + const additionalNFTID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner, + attributesArray: [], + }, + ], + userSubstore: [ + { + address: owner, + nftID, + lockingModule: 'pos', + }, + { + address: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: additionalNFTID, + lockingModule: 'pos', + }, + ], + escrowSubstore: [], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${additionalNFTID.toString( + 'hex', + )} in UserSubstore has no corresponding entry for NFTSubstore`, + ); + }); + + it('should throw if an NFT in escrow store has no corresponding entry for nft store', async () => { + const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + const additionalNFTID = utils.getRandomBytes(LENGTH_NFT_ID); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID, + owner: escrowedChainID, + attributesArray: [], + }, + ], + userSubstore: [], + escrowSubstore: [ + { + nftID, + escrowedChainID, + }, + { + nftID: additionalNFTID, + escrowedChainID, + }, + ], + }; + + const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); + + await expect(module.initGenesisState(context)).rejects.toThrow( + `nftID ${additionalNFTID.toString( + 'hex', + )} in EscrowSubstore has no corresponding entry for NFTSubstore`, + ); + }); + it('should throw if all NFTs are supported and SupportedNFTsSubstore contains more than one entry', async () => { const genesisAssets = { ...validData, @@ -473,6 +555,7 @@ describe('nft module', () => { lockingModule: 'pos', }, ], + escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -517,6 +600,7 @@ describe('nft module', () => { lockingModule: 'pos', }, ], + escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -552,6 +636,7 @@ describe('nft module', () => { lockingModule: 'token', }, ], + escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -651,6 +736,11 @@ describe('nft module', () => { owner: escrowedChainID, attributesArray: [], }, + { + nftID: escrowedNFTID2, + owner: escrowedChainID, + attributesArray: [], + }, ], userSubstore: [ { From d0a501ddb72532eb4294abd5991120d7502b3d10 Mon Sep 17 00:00:00 2001 From: has5aan Date: Thu, 22 Jun 2023 11:31:58 +0200 Subject: [PATCH 34/58] :recycle: NFTModule.initGenesisState --- framework/src/modules/nft/module.ts | 110 +---- framework/src/modules/nft/schemas.ts | 54 +-- framework/src/modules/nft/types.ts | 9 - .../nft/init_genesis_state_fixtures.ts | 160 ------- .../test/unit/modules/nft/module.spec.ts | 443 ++---------------- 5 files changed, 59 insertions(+), 717 deletions(-) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 55719225028..019d87642b8 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -63,7 +63,12 @@ import { FeeMethod, GenesisNFTStore, TokenMethod } from './types'; import { CrossChainTransferCommand as CrossChainTransferMessageCommand } from './cc_commands/cc_transfer'; import { TransferCrossChainCommand } from './commands/transfer_cross_chain'; import { TransferCommand } from './commands/transfer'; -import { ALL_SUPPORTED_NFTS_KEY, LENGTH_ADDRESS, LENGTH_CHAIN_ID } from './constants'; +import { + ALL_SUPPORTED_NFTS_KEY, + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + NFT_NOT_LOCKED, +} from './constants'; export class NFTModule extends BaseInteroperableModule { public method = new NFTMethod(this.stores, this.events); @@ -195,65 +200,11 @@ export class NFTModule extends BaseInteroperableModule { if (![LENGTH_CHAIN_ID, LENGTH_ADDRESS].includes(nft.owner.length)) { throw new Error(`nftID ${nft.nftID.toString('hex')} has invalid owner`); } + if (nftIDKeySet.has(nft.nftID)) { throw new Error(`nftID ${nft.nftID.toString('hex')} duplicated`); } - nftIDKeySet.add(nft.nftID); - } - - for (const nft of genesisStore.nftSubstore) { - const userStoreEntries = genesisStore.userSubstore.filter(userStoreEntry => - userStoreEntry.nftID.equals(nft.nftID), - ); - - const escrowStoreEntries = genesisStore.escrowSubstore.filter(escrowEntry => - escrowEntry.nftID.equals(nft.nftID), - ); - - if (userStoreEntries.length === 0 && escrowStoreEntries.length === 0) { - throw new Error( - `nftID ${nft.nftID.toString( - 'hex', - )} has no corresponding entry for UserSubstore or EscrowSubstore`, - ); - } - - if (userStoreEntries.length > 0 && escrowStoreEntries.length > 0) { - throw new Error( - `nftID ${nft.nftID.toString( - 'hex', - )} has an entry for both UserSubstore and EscrowSubstore`, - ); - } - - const ownerUsers = genesisStore.userSubstore.filter( - userEntry => userEntry.nftID.equals(nft.nftID) && userEntry.address.equals(nft.owner), - ); - - const ownerChains = genesisStore.escrowSubstore.filter( - escrowEntry => - escrowEntry.nftID.equals(nft.nftID) && escrowEntry.escrowedChainID.equals(nft.owner), - ); - - if (ownerUsers.length > 1) { - throw new Error(`nftID ${nft.nftID.toString('hex')} has multiple entries for UserSubstore`); - } - - if (ownerChains.length > 1) { - throw new Error( - `nftID ${nft.nftID.toString('hex')} has multiple entries for EscrowSubstore`, - ); - } - - if (nft.owner.length === LENGTH_CHAIN_ID && ownerChains.length !== 1) { - throw new Error( - `nftID ${nft.nftID.toString( - 'hex', - )} should have a corresponding entry for EscrowSubstore only`, - ); - } - const attributeSet: Record = {}; for (const attribute of nft.attributesArray) { @@ -267,26 +218,8 @@ export class NFTModule extends BaseInteroperableModule { ); } } - } - for (const user of genesisStore.userSubstore) { - if (!genesisStore.nftSubstore.some(nft => nft.nftID.equals(user.nftID))) { - throw new Error( - `nftID ${user.nftID.toString( - 'hex', - )} in UserSubstore has no corresponding entry for NFTSubstore`, - ); - } - } - - for (const escrow of genesisStore.escrowSubstore) { - if (!genesisStore.nftSubstore.some(nft => nft.nftID.equals(escrow.nftID))) { - throw new Error( - `nftID ${escrow.nftID.toString( - 'hex', - )} in EscrowSubstore has no corresponding entry for NFTSubstore`, - ); - } + nftIDKeySet.add(nft.nftID); } const allNFTsSupported = genesisStore.supportedNFTsSubstore.some(supportedNFTs => @@ -316,29 +249,24 @@ export class NFTModule extends BaseInteroperableModule { } const nftStore = this.stores.get(NFTStore); + const escrowStore = this.stores.get(EscrowStore); + const userStore = this.stores.get(UserStore); + for (const nft of genesisStore.nftSubstore) { - const { nftID, owner, attributesArray } = nft; + const { owner, nftID, attributesArray } = nft; await nftStore.save(context, nftID, { owner, attributesArray, }); - } - - const userStore = this.stores.get(UserStore); - for (const user of genesisStore.userSubstore) { - const { address, nftID, lockingModule } = user; - - await userStore.set(context, userStore.getKey(address, nftID), { - lockingModule, - }); - } - const escrowStore = this.stores.get(EscrowStore); - for (const escrow of genesisStore.escrowSubstore) { - const { escrowedChainID, nftID } = escrow; - - await escrowStore.set(context, escrowStore.getKey(escrowedChainID, nftID), {}); + if (owner.length === LENGTH_CHAIN_ID) { + await escrowStore.set(context, escrowStore.getKey(owner, nftID), {}); + } else { + await userStore.set(context, userStore.getKey(owner, nftID), { + lockingModule: NFT_NOT_LOCKED, + }); + } } for (const supportedNFT of genesisStore.supportedNFTsSubstore) { diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index dbe020974a6..c3bfbc15e69 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -391,7 +391,7 @@ export const isNFTSupportedResponseSchema = { export const genesisNFTStoreSchema = { $id: '/nft/module/genesis', type: 'object', - required: ['nftSubstore', 'userSubstore', 'escrowSubstore', 'supportedNFTsSubstore'], + required: ['nftSubstore', 'supportedNFTsSubstore'], properties: { nftSubstore: { type: 'array', @@ -434,59 +434,9 @@ export const genesisNFTStoreSchema = { }, }, }, - userSubstore: { - type: 'array', - fieldNumber: 2, - items: { - type: 'object', - required: ['address', 'nftID', 'lockingModule'], - properties: { - address: { - dataType: 'bytes', - format: 'lisk32', - fieldNumber: 1, - }, - nftID: { - dataType: 'bytes', - minLength: LENGTH_NFT_ID, - maxLength: LENGTH_NFT_ID, - fieldNumber: 2, - }, - lockingModule: { - dataType: 'string', - minLength: MIN_LENGTH_MODULE_NAME, - maxLength: MAX_LENGTH_MODULE_NAME, - pattern: '^[a-zA-Z0-9]*$', - fieldNumber: 3, - }, - }, - }, - }, - escrowSubstore: { - type: 'array', - fieldNumber: 3, - items: { - type: 'object', - required: ['escrowedChainID', 'nftID'], - properties: { - escrowedChainID: { - dataType: 'bytes', - minLength: LENGTH_CHAIN_ID, - maxLength: LENGTH_CHAIN_ID, - fieldNumber: 1, - }, - nftID: { - dataType: 'bytes', - minLength: LENGTH_NFT_ID, - maxLength: LENGTH_NFT_ID, - fieldNumber: 2, - }, - }, - }, - }, supportedNFTsSubstore: { type: 'array', - fieldNumber: 4, + fieldNumber: 2, items: { type: 'object', required: ['chainID', 'supportedCollectionIDArray'], diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 1438bc4daef..8b1647d67c2 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -74,15 +74,6 @@ export interface GenesisNFTStore { attributes: Buffer; }[]; }[]; - userSubstore: { - address: Buffer; - nftID: Buffer; - lockingModule: string; - }[]; - escrowSubstore: { - escrowedChainID: Buffer; - nftID: Buffer; - }[]; supportedNFTsSubstore: { chainID: Buffer; supportedCollectionIDArray: { diff --git a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts index 6684b639c65..3c7a8bb338e 100644 --- a/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -63,24 +63,6 @@ export const validData: GenesisNFTStore = { attributesArray: [], }, ], - userSubstore: [ - { - address: owner, - nftID: nftID1, - lockingModule: 'pos', - }, - { - address: owner, - nftID: nftID2, - lockingModule: 'token', - }, - ], - escrowSubstore: [ - { - escrowedChainID, - nftID: nftID3, - }, - ], supportedNFTsSubstore: [ { chainID: utils.getRandomBytes(LENGTH_CHAIN_ID), @@ -179,148 +161,6 @@ export const invalidSchemaNFTSubstoreGenesisAssets = [ ], ]; -export const invalidSchemaUserSubstoreGenesisAssets = [ - [ - 'Invalid owner address', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS - 1), - nftID: nftID1, - lockingModule: 'pos', - }, - ], - }, - `address' address length invalid`, - ], - [ - 'Invalid nftID - minimum length not satisified', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID - 1), - lockingModule: 'pos', - }, - ], - }, - `nftID' minLength not satisfied`, - ], - [ - 'Invalid nftID - maximum length exceeded', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), - lockingModule: 'pos', - }, - ], - }, - `nftID' maxLength exceeded`, - ], - [ - 'Invalid lockingModule - minimum length not satisfied', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - lockingModule: '', - }, - ], - }, - `lockingModule' must NOT have fewer than 1 characters`, - ], - [ - 'Invalid lockingModule - maximum length exceeded', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - lockingModule: 'pos'.repeat(33), - }, - ], - }, - `lockingModule' must NOT have more than 32 characters`, - ], - [ - 'lockingModule must match pattern - "^[a-zA-Z0-9]*$"', - { - ...validData, - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: utils.getRandomBytes(LENGTH_NFT_ID), - lockingModule: '$#pos"', - }, - ], - }, - `must match pattern "^[a-zA-Z0-9]*$"`, - ], -]; - -export const invalidSchemaEscrowSubstoreGenesisAssets = [ - [ - 'Invalid escrowedChainID - minimum length not satisfied', - { - ...validData, - escrowSubstore: [ - { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), - nftID: nftID1, - }, - ], - }, - `escrowedChainID' minLength not satisfied`, - ], - [ - 'Invalid escrowedChainID - maximum length exceeded', - { - ...validData, - escrowSubstore: [ - { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID + 1), - nftID: nftID1, - }, - ], - }, - `escrowedChainID' maxLength exceeded`, - ], - [ - 'Invalid nftID - minimum length not satisfied', - { - ...validData, - escrowSubstore: [ - { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), - nftID: utils.getRandomBytes(LENGTH_CHAIN_ID - 1), - }, - ], - }, - `nftID' minLength not satisfied`, - ], - [ - 'Invalid nftID - maximum length exceeded', - { - ...validData, - escrowSubstore: [ - { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), - nftID: utils.getRandomBytes(LENGTH_NFT_ID + 1), - }, - ], - }, - `nftID' maxLength exceeded`, - ], -]; - export const invalidSchemaSupportedNFTsSubstoreGenesisAssets = [ [ 'Invalid collectionID - minimum length not satisfied', diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts index c244432f8f9..397124fa95b 100644 --- a/framework/test/unit/modules/nft/module.spec.ts +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -18,10 +18,8 @@ import { BlockAssets } from '@liskhq/lisk-chain'; import { NFTModule } from '../../../../src/modules/nft/module'; import { createGenesisBlockContext } from '../../../../src/testing'; import { - invalidSchemaEscrowSubstoreGenesisAssets, invalidSchemaNFTSubstoreGenesisAssets, invalidSchemaSupportedNFTsSubstoreGenesisAssets, - invalidSchemaUserSubstoreGenesisAssets, validData, } from './init_genesis_state_fixtures'; import { genesisNFTStoreSchema } from '../../../../src/modules/nft/schemas'; @@ -31,6 +29,7 @@ import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, LENGTH_NFT_ID, + NFT_NOT_LOCKED, } from '../../../../src/modules/nft/constants'; import { NFTStore } from '../../../../src/modules/nft/stores/nft'; import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; @@ -42,8 +41,8 @@ describe('nft module', () => { const nftStore = module.stores.get(NFTStore); const userStore = module.stores.get(UserStore); - const supportedNFTsSubstore = module.stores.get(SupportedNFTsStore); const escrowStore = module.stores.get(EscrowStore); + const supportedNFTsSubstore = module.stores.get(SupportedNFTsStore); const createGenesisBlockContextFromGenesisAssets = (genesisAssets: object) => { const encodedAsset = codec.encode(genesisNFTStoreSchema, genesisAssets); @@ -72,38 +71,6 @@ describe('nft module', () => { }); }); - describe('validate userSubstore schema', () => { - it.each(invalidSchemaUserSubstoreGenesisAssets)('%s', async (_, input, err) => { - if (typeof input === 'string') { - return; - } - - const encodedAsset = codec.encode(genesisNFTStoreSchema, input); - - const context = createGenesisBlockContext({ - assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), - }).createInitGenesisStateContext(); - - await expect(module.initGenesisState(context)).rejects.toThrow(err as string); - }); - }); - - describe('validate escrowSubstore schema', () => { - it.each(invalidSchemaEscrowSubstoreGenesisAssets)('%s', async (_, input, err) => { - if (typeof input === 'string') { - return; - } - - const encodedAsset = codec.encode(genesisNFTStoreSchema, input); - - const context = createGenesisBlockContext({ - assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), - }).createInitGenesisStateContext(); - - await expect(module.initGenesisState(context)).rejects.toThrow(err as string); - }); - }); - describe('validate supportedNFTsSubstore schema', () => { it.each(invalidSchemaSupportedNFTsSubstoreGenesisAssets)('%s', async (_, input, err) => { if (typeof input === 'string') { @@ -188,162 +155,7 @@ describe('nft module', () => { ); }); - it('should throw if NFT does not have a corresponding entry for user or escrow store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner: utils.getRandomBytes(LENGTH_ADDRESS), - attributesArray: [], - }, - ], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${nftID.toString( - 'hex', - )} has no corresponding entry for UserSubstore or EscrowSubstore`, - ); - }); - - it('should throw if NFT has an entry for both user and escrow store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - // const owner = utils.getRandomBytes(LENGTH_ADDRESS); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner: utils.getRandomBytes(LENGTH_ADDRESS), - attributesArray: [], - }, - ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID, - lockingModule: 'pos', - }, - ], - escrowSubstore: [ - { - escrowedChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), - nftID, - }, - ], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${nftID.toString('hex')} has an entry for both UserSubstore and EscrowSubstore`, - ); - }); - - it('should throw if NFT has multiple entries for user store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - const owner = utils.getRandomBytes(LENGTH_ADDRESS); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner, - attributesArray: [], - }, - ], - userSubstore: [ - { - address: owner, - nftID, - lockingModule: 'pos', - }, - { - address: owner, - nftID, - lockingModule: 'token', - }, - ], - escrowSubstore: [], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${nftID.toString('hex')} has multiple entries for UserSubstore`, - ); - }); - - it('should throw if NFT has multiple entries for escrow store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner: escrowedChainID, - attributesArray: [], - }, - ], - userSubstore: [], - escrowSubstore: [ - { - escrowedChainID, - nftID, - }, - { - escrowedChainID, - nftID, - }, - ], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${nftID.toString('hex')} has multiple entries for EscrowSubstore`, - ); - }); - - it('should throw if escrowed NFT has no corresponding entry for escrow store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: [], - }, - ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID, - lockingModule: 'pos', - }, - ], - escrowSubstore: [], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${nftID.toString('hex')} should have a corresponding entry for EscrowSubstore only`, - ); - }); - - it('should throw if NFT has duplicate attribute for an array', async () => { + it('should throw if NFT has duplicate attribute for a module', async () => { const nftID = utils.getRandomBytes(LENGTH_NFT_ID); const moduleName = 'pos'; @@ -365,13 +177,6 @@ describe('nft module', () => { ], }, ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID, - lockingModule: 'pos', - }, - ], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -381,82 +186,6 @@ describe('nft module', () => { ); }); - it('should throw if an NFT in user store has no corresponding entry for nft store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - const owner = utils.getRandomBytes(LENGTH_ADDRESS); - - const additionalNFTID = utils.getRandomBytes(LENGTH_NFT_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner, - attributesArray: [], - }, - ], - userSubstore: [ - { - address: owner, - nftID, - lockingModule: 'pos', - }, - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: additionalNFTID, - lockingModule: 'pos', - }, - ], - escrowSubstore: [], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${additionalNFTID.toString( - 'hex', - )} in UserSubstore has no corresponding entry for NFTSubstore`, - ); - }); - - it('should throw if an NFT in escrow store has no corresponding entry for nft store', async () => { - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); - const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - - const additionalNFTID = utils.getRandomBytes(LENGTH_NFT_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID, - owner: escrowedChainID, - attributesArray: [], - }, - ], - userSubstore: [], - escrowSubstore: [ - { - nftID, - escrowedChainID, - }, - { - nftID: additionalNFTID, - escrowedChainID, - }, - ], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).rejects.toThrow( - `nftID ${additionalNFTID.toString( - 'hex', - )} in EscrowSubstore has no corresponding entry for NFTSubstore`, - ); - }); - it('should throw if all NFTs are supported and SupportedNFTsSubstore contains more than one entry', async () => { const genesisAssets = { ...validData, @@ -525,6 +254,41 @@ describe('nft module', () => { ); }); + it('should create NFTs, their corresponding user or escrow entries and supported chains', async () => { + const context = createGenesisBlockContextFromGenesisAssets(validData); + + await expect(module.initGenesisState(context)).resolves.toBeUndefined(); + + for (const nft of validData.nftSubstore) { + const { nftID, owner, attributesArray } = nft; + + await expect(nftStore.get(context.getMethodContext(), nftID)).resolves.toEqual({ + owner, + attributesArray, + }); + + if (owner.length === LENGTH_CHAIN_ID) { + await expect( + escrowStore.get(context.getMethodContext(), escrowStore.getKey(owner, nftID)), + ).resolves.toEqual({}); + } else { + await expect( + userStore.get(context.getMethodContext(), userStore.getKey(owner, nftID)), + ).resolves.toEqual({ + lockingModule: NFT_NOT_LOCKED, + }); + } + } + + for (const supportedChain of validData.supportedNFTsSubstore) { + const { chainID, supportedCollectionIDArray } = supportedChain; + + await expect( + supportedNFTsSubstore.get(context.getMethodContext(), chainID), + ).resolves.toEqual({ supportedCollectionIDArray }); + } + }); + it('should create entries for all NFTs lexicographically', async () => { const nftID1 = Buffer.alloc(LENGTH_NFT_ID, 1); const nftID2 = Buffer.alloc(LENGTH_NFT_ID, 0); @@ -543,19 +307,6 @@ describe('nft module', () => { attributesArray: [], }, ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: nftID1, - lockingModule: 'pos', - }, - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID: nftID2, - lockingModule: 'pos', - }, - ], - escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -593,14 +344,6 @@ describe('nft module', () => { ], }, ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID, - lockingModule: 'pos', - }, - ], - escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -629,14 +372,6 @@ describe('nft module', () => { ], }, ], - userSubstore: [ - { - address: utils.getRandomBytes(LENGTH_ADDRESS), - nftID, - lockingModule: 'token', - }, - ], - escrowSubstore: [], }; const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); @@ -700,108 +435,6 @@ describe('nft module', () => { expect(expectedKeys).toEqual(allSupportedNFTs.map(supportedNFTs => supportedNFTs.key)); }); - it('should create entries for user and escrow store', async () => { - const nftID1 = utils.getRandomBytes(LENGTH_NFT_ID); - const nftID2 = utils.getRandomBytes(LENGTH_NFT_ID); - const nftID3 = utils.getRandomBytes(LENGTH_NFT_ID); - - const escrowedNFTID1 = utils.getRandomBytes(LENGTH_NFT_ID); - const escrowedNFTID2 = utils.getRandomBytes(LENGTH_NFT_ID); - - const owner1 = utils.getRandomBytes(LENGTH_ADDRESS); - const owner2 = utils.getRandomBytes(LENGTH_ADDRESS); - - const escrowedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - - const genesisAssets = { - ...validData, - nftSubstore: [ - { - nftID: nftID1, - owner: owner1, - attributesArray: [], - }, - { - nftID: nftID2, - owner: owner1, - attributesArray: [], - }, - { - nftID: nftID3, - owner: owner2, - attributesArray: [], - }, - { - nftID: escrowedNFTID1, - owner: escrowedChainID, - attributesArray: [], - }, - { - nftID: escrowedNFTID2, - owner: escrowedChainID, - attributesArray: [], - }, - ], - userSubstore: [ - { - address: owner1, - nftID: nftID1, - lockingModule: 'pos', - }, - { - address: owner1, - nftID: nftID2, - lockingModule: 'token', - }, - { - address: owner2, - nftID: nftID3, - lockingModule: 'auth', - }, - ], - escrowSubstore: [ - { - escrowedChainID, - nftID: escrowedNFTID1, - }, - { - escrowedChainID, - nftID: escrowedNFTID2, - }, - ], - }; - - const context = createGenesisBlockContextFromGenesisAssets(genesisAssets); - - await expect(module.initGenesisState(context)).resolves.toBeUndefined(); - - await expect( - userStore.get(context.getMethodContext(), userStore.getKey(owner1, nftID1)), - ).resolves.toEqual({ lockingModule: 'pos' }); - - await expect( - userStore.get(context.getMethodContext(), userStore.getKey(owner1, nftID2)), - ).resolves.toEqual({ lockingModule: 'token' }); - - await expect( - userStore.get(context.getMethodContext(), userStore.getKey(owner2, nftID3)), - ).resolves.toEqual({ lockingModule: 'auth' }); - - await expect( - escrowStore.get( - context.getMethodContext(), - escrowStore.getKey(escrowedChainID, escrowedNFTID1), - ), - ).resolves.toEqual({}); - - await expect( - escrowStore.get( - context.getMethodContext(), - escrowStore.getKey(escrowedChainID, escrowedNFTID2), - ), - ).resolves.toEqual({}); - }); - it('should create an entry for supported chains with supportedCollectionIDArray sorted lexicographically', async () => { const chainID = utils.getRandomBytes(LENGTH_CHAIN_ID); From 67043ddca2039dc56ad895fc4394195fe9f85d94 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 23 Jun 2023 02:43:57 +0100 Subject: [PATCH 35/58] Update NFT module with additional checks and LIP updates (#8635) * Update per lip pr * Revert token module changes * Use context chain id per feedback --- .../modules/nft/cc_commands/cc_transfer.ts | 4 +-- .../nft/commands/transfer_cross_chain.ts | 4 +++ framework/src/modules/nft/method.ts | 17 ++++++++++- .../nft/cc_comands/cc_transfer.spec.ts | 28 +++++++++++++++-- .../nft/commands/transfer_cross_chain.spec.ts | 12 ++++++++ .../test/unit/modules/nft/method.spec.ts | 30 +++++++++++++++++++ 6 files changed, 90 insertions(+), 5 deletions(-) diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts index af4045cd2a8..ea61435c300 100644 --- a/framework/src/modules/nft/cc_commands/cc_transfer.ts +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -62,7 +62,7 @@ export class CrossChainTransferCommand extends BaseCCCommand { const { nftID } = params; const { sendingChainID } = ccm; const nftChainID = this._method.getChainID(nftID); - const ownChainID = this._internalMethod.getOwnChainID(); + const ownChainID = context.chainID; if (![ownChainID, sendingChainID].some(allowedChainID => nftChainID.equals(allowedChainID))) { throw new Error('NFT is not native to either the sending chain or the receiving chain'); @@ -94,7 +94,7 @@ export class CrossChainTransferCommand extends BaseCCCommand { const { sendingChainID, status } = ccm; const { nftID, senderAddress, attributesArray: receivedAttributes } = params; const nftChainID = this._method.getChainID(nftID); - const ownChainID = this._internalMethod.getOwnChainID(); + const ownChainID = context.chainID; const nftStore = this.stores.get(NFTStore); const escrowStore = this.stores.get(EscrowStore); let recipientAddress: Buffer; diff --git a/framework/src/modules/nft/commands/transfer_cross_chain.ts b/framework/src/modules/nft/commands/transfer_cross_chain.ts index 97fd17a8826..fad28836ca6 100644 --- a/framework/src/modules/nft/commands/transfer_cross_chain.ts +++ b/framework/src/modules/nft/commands/transfer_cross_chain.ts @@ -66,6 +66,10 @@ export class TransferCrossChainCommand extends BaseCommand { const nftStore = this.stores.get(NFTStore); const nftExists = await nftStore.has(context.getMethodContext(), params.nftID); + if (params.receivingChainID.equals(context.chainID)) { + throw new Error('Receiving chain cannot be the sending chain'); + } + if (!nftExists) { throw new Error('NFT substore entry does not exist'); } diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 5c4e38e4af5..3d1fa187352 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -527,6 +527,22 @@ export class NFTMethod extends BaseMethod { data: string, includeAttributes: boolean, ): Promise { + const ownChainID = this._internalMethod.getOwnChainID(); + if (receivingChainID.equals(ownChainID)) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.INVALID_RECEIVING_CHAIN, + ); + throw new Error('Receiving chain cannot be the sending chain'); + } + if (data.length > MAX_LENGTH_DATA) { this.events.get(TransferCrossChainEvent).error( methodContext, @@ -576,7 +592,6 @@ export class NFTMethod extends BaseMethod { } const nftChainID = this.getChainID(nftID); - const ownChainID = this._internalMethod.getOwnChainID(); if (![ownChainID, receivingChainID].some(allowedChainID => nftChainID.equals(allowedChainID))) { this.events.get(TransferCrossChainEvent).error( methodContext, diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index 62f7e01c207..354f587b1b2 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -174,7 +174,7 @@ describe('CrossChain Transfer Command', () => { eventQueue: new EventQueue(0), getStore, logger: fakeLogger, - chainID, + chainID: ownChainID, }; }); @@ -315,6 +315,18 @@ describe('CrossChain Transfer Command', () => { method.init(newConfig); internalMethod.addDependencies(method, interopMethod); internalMethod.init(newConfig); + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID: newConfig.ownChainID, + }; await expect(command.verify(context)).rejects.toThrow('NFT substore entry already exists'); }); @@ -435,7 +447,7 @@ describe('CrossChain Transfer Command', () => { eventQueue: new EventQueue(0), getStore, logger: fakeLogger, - chainID, + chainID: ownChainID, }; await expect(command.execute(context)).resolves.toBeUndefined(); @@ -524,6 +536,18 @@ describe('CrossChain Transfer Command', () => { method.init(newConfig); internalMethod.addDependencies(method, interopMethod); internalMethod.init(newConfig); + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID: newConfig.ownChainID, + }; const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { supportedCollectionIDArray: [], diff --git a/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts index ba942e60893..897a8ae0732 100644 --- a/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts +++ b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts @@ -204,6 +204,18 @@ describe('TransferCrossChainComand', () => { }); describe('verify', () => { + it('should fail if receiving chain id is same as the own chain id', async () => { + const receivingChainIDContext = createTransactionContextWithOverridingParams({ + receivingChainID: ownChainID, + }); + + await expect( + command.verify( + receivingChainIDContext.createCommandVerifyContext(crossChainTransferParamsSchema), + ), + ).rejects.toThrow('Receiving chain cannot be the sending chain'); + }); + it('should fail if NFT does not have valid length', async () => { const nftMinLengthContext = createTransactionContextWithOverridingParams({ nftID: utils.getRandomBytes(LENGTH_NFT_ID - 1), diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 2dd3d81568a..7e214516514 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -882,6 +882,36 @@ describe('NFTMethod', () => { receivingChainID = existingNFT.nftID.slice(0, LENGTH_CHAIN_ID); }); + it('should throw and emit error transfer cross chain event if receiving chain id is same as the own chain id', async () => { + receivingChainID = config.ownChainID; + await expect( + method.transferCrossChain( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Receiving chain cannot be the sending chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: existingNFT.owner, + recipientAddress, + receivingChainID, + nftID: existingNFT.nftID, + includeAttributes, + }, + NftEventResult.INVALID_RECEIVING_CHAIN, + ); + }); + it('should throw and emit error transfer cross chain event if nft does not exist', async () => { receivingChainID = nftID.slice(0, LENGTH_CHAIN_ID); await expect( From c9e7c1a097a22deaf8fd3e90cde6702e6f33f537 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Mon, 26 Jun 2023 08:27:54 +0100 Subject: [PATCH 36/58] Update methods createNFTEntry and create of NFT module with additional checks (#8654) Update per lip --- framework/src/modules/nft/internal_method.ts | 9 +++++++++ framework/src/modules/nft/method.ts | 8 ++++++++ .../unit/modules/nft/internal_method.spec.ts | 19 ++++++++++++++++++- .../test/unit/modules/nft/method.spec.ts | 17 +++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index ac271432678..6b87cbb7aea 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -67,6 +67,15 @@ export class InternalMethod extends BaseMethod { nftID: Buffer, attributesArray: NFTAttributes[], ): Promise { + const moduleNames = []; + for (const item of attributesArray) { + moduleNames.push(item.module); + } + + if (new Set(moduleNames).size !== attributesArray.length) { + throw new Error('Invalid attributes array provided'); + } + const nftStore = this.stores.get(NFTStore); await nftStore.save(methodContext, nftID, { owner: address, diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 3d1fa187352..3039737a15d 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -301,6 +301,14 @@ export class NFTMethod extends BaseMethod { collectionID: Buffer, attributesArray: NFTAttributes[], ): Promise { + const moduleNames = []; + for (const item of attributesArray) { + moduleNames.push(item.module); + } + if (new Set(moduleNames).size !== attributesArray.length) { + throw new Error('Invalid attributes array provided'); + } + const index = await this.getNextAvailableIndex(methodContext, collectionID); const nftID = Buffer.concat([ this._config.ownChainID, diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 31dcd1a99c4..4817a89d45d 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -104,7 +104,24 @@ describe('InternalMethod', () => { }); describe('createNFTEntry', () => { - it('should create an entry in NFStore with attributes sorted by module', async () => { + it('should throw for duplicate module names in attributes array', async () => { + const attributesArray = [ + { + module: 'token', + attributes: Buffer.alloc(8, 1), + }, + { + module: 'token', + attributes: Buffer.alloc(8, 2), + }, + ]; + + await expect( + internalMethod.createNFTEntry(methodContext, address, nftID, attributesArray), + ).rejects.toThrow('Invalid attributes array provided'); + }); + + it('should create an entry in NFStore with attributes sorted by module if there is no duplicate module name', async () => { const unsortedAttributesArray = [ { module: 'token', diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 7e214516514..d5bcd5a9c07 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -556,6 +556,23 @@ describe('NFTMethod', () => { jest.spyOn(feeMethod, 'payFee'); }); + it('should throw for duplicate module names in attributes array', async () => { + const attributesArray = [ + { + module: 'token', + attributes: Buffer.alloc(8, 1), + }, + { + module: 'token', + attributes: Buffer.alloc(8, 2), + }, + ]; + + await expect( + method.create(methodContext, address, collectionID, attributesArray), + ).rejects.toThrow('Invalid attributes array provided'); + }); + it('should set data to stores with correct key and emit successfull create event when there is no entry in the nft substore', async () => { const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('0')]); From 4e09d473f387b986a6e30878d6fdcd748895518a Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:47:33 +0200 Subject: [PATCH 37/58] Add nft module to example app --- .../config/default/genesis_assets.json | 87 +++++++++++++++++++ framework/src/application.ts | 7 ++ 2 files changed, 94 insertions(+) diff --git a/examples/pos-mainchain/config/default/genesis_assets.json b/examples/pos-mainchain/config/default/genesis_assets.json index d575139c811..fbe304669c9 100644 --- a/examples/pos-mainchain/config/default/genesis_assets.json +++ b/examples/pos-mainchain/config/default/genesis_assets.json @@ -1041,6 +1041,93 @@ } } }, + { + "module": "nft", + "data": { + "nftSubstore": [], + "supportedNFTsSubstore": [] + }, + "schema": { + "$id": "/nft/module/genesis", + "type": "object", + "required": ["nftSubstore", "supportedNFTsSubstore"], + "properties": { + "nftSubstore": { + "type": "array", + "fieldNumber": 1, + "items": { + "type": "object", + "required": ["nftID", "owner", "attributesArray"], + "properties": { + "nftID": { + "dataType": "bytes", + "minLength": 16, + "maxLength": 16, + "fieldNumber": 1 + }, + "owner": { + "dataType": "bytes", + "fieldNumber": 2 + }, + "attributesArray": { + "type": "array", + "fieldNumber": 3, + "items": { + "type": "object", + "required": ["module", "attributes"], + "properties": { + "module": { + "dataType": "string", + "minLength": 1, + "maxLength": 1, + "pattern": "^[a-zA-Z0-9]*$", + "fieldNumber": 1 + }, + "attributes": { + "dataType": "bytes", + "fieldNumber": 2 + } + } + } + } + } + } + }, + "supportedNFTsSubstore": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["chainID", "supportedCollectionIDArray"], + "properties": { + "chainID": { + "dataType": "bytes", + "fieldNumber": 1, + "minLength": 8, + "maxLength": 8 + }, + "supportedCollectionIDArray": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["collectionID"], + "properties": { + "collectionID": { + "dataType": "bytes", + "minLength": 4, + "maxLength": 4, + "fieldNumber": 1 + } + } + } + } + } + } + } + } + } + }, { "module": "pos", "data": { diff --git a/framework/src/application.ts b/framework/src/application.ts index e91be0fecd0..20a01d59eb5 100644 --- a/framework/src/application.ts +++ b/framework/src/application.ts @@ -55,6 +55,7 @@ import { } from './modules/interoperability'; import { DynamicRewardMethod, DynamicRewardModule } from './modules/dynamic_rewards'; import { Engine } from './engine'; +import { NFTMethod, NFTModule } from './modules/nft'; const isPidRunning = async (pid: number): Promise => psList().then(list => list.some(x => x.pid === pid)); @@ -108,6 +109,7 @@ interface DefaultApplication { validator: ValidatorsMethod; auth: AuthMethod; token: TokenMethod; + nft: NFTMethod; fee: FeeMethod; random: RandomMethod; reward: DynamicRewardMethod; @@ -163,6 +165,7 @@ export class Application { // create module instances const authModule = new AuthModule(); const tokenModule = new TokenModule(); + const nftModule = new NFTModule(); const feeModule = new FeeModule(); const rewardModule = new DynamicRewardModule(); const randomModule = new RandomModule(); @@ -192,9 +195,11 @@ export class Application { feeModule.method, ); tokenModule.addDependencies(interoperabilityModule.method, feeModule.method); + nftModule.addDependencies(interoperabilityModule.method, feeModule.method, tokenModule.method); // resolve interoperability dependencies interoperabilityModule.registerInteroperableModule(tokenModule); + interoperabilityModule.registerInteroperableModule(nftModule); interoperabilityModule.registerInteroperableModule(feeModule); // register modules @@ -202,6 +207,7 @@ export class Application { application._registerModule(authModule); application._registerModule(validatorModule); application._registerModule(tokenModule); + application._registerModule(nftModule); application._registerModule(rewardModule); application._registerModule(randomModule); application._registerModule(posModule); @@ -212,6 +218,7 @@ export class Application { method: { validator: validatorModule.method, token: tokenModule.method, + nft: nftModule.method, auth: authModule.method, fee: feeModule.method, pos: posModule.method, From dd83a93f38ce944e36e7a417828c892be9980b60 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Wed, 28 Jun 2023 19:57:23 +0200 Subject: [PATCH 38/58] Add & register custom module and nft module --- .../config/default/genesis_assets.json | 4 +- examples/pos-mainchain/package.json | 1 + examples/pos-mainchain/src/app/app.ts | 15 ++++- examples/pos-mainchain/src/app/modules.ts | 6 +- .../app/modules/testNft/commands/mint_nft.ts | 61 +++++++++++++++++++ .../src/app/modules/testNft/constants.ts | 17 ++++++ .../src/app/modules/testNft/endpoint.ts | 17 ++++++ .../src/app/modules/testNft/method.ts | 17 ++++++ .../src/app/modules/testNft/module.ts | 51 ++++++++++++++++ .../src/app/modules/testNft/types.ts | 60 ++++++++++++++++++ framework/src/application.ts | 7 --- framework/src/index.ts | 1 + 12 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts create mode 100644 examples/pos-mainchain/src/app/modules/testNft/constants.ts create mode 100644 examples/pos-mainchain/src/app/modules/testNft/endpoint.ts create mode 100644 examples/pos-mainchain/src/app/modules/testNft/method.ts create mode 100644 examples/pos-mainchain/src/app/modules/testNft/module.ts create mode 100644 examples/pos-mainchain/src/app/modules/testNft/types.ts diff --git a/examples/pos-mainchain/config/default/genesis_assets.json b/examples/pos-mainchain/config/default/genesis_assets.json index fbe304669c9..dd8d2b096fc 100644 --- a/examples/pos-mainchain/config/default/genesis_assets.json +++ b/examples/pos-mainchain/config/default/genesis_assets.json @@ -1102,9 +1102,7 @@ "properties": { "chainID": { "dataType": "bytes", - "fieldNumber": 1, - "minLength": 8, - "maxLength": 8 + "fieldNumber": 1 }, "supportedCollectionIDArray": { "type": "array", diff --git a/examples/pos-mainchain/package.json b/examples/pos-mainchain/package.json index 275cce15599..f26366b8cd4 100755 --- a/examples/pos-mainchain/package.json +++ b/examples/pos-mainchain/package.json @@ -111,6 +111,7 @@ } }, "dependencies": { + "@liskhq/lisk-validator": "^0.7.0-beta.0", "@liskhq/lisk-framework-dashboard-plugin": "^0.2.0-alpha.7", "@liskhq/lisk-framework-faucet-plugin": "^0.2.0-alpha.7", "@liskhq/lisk-framework-forger-plugin": "^0.3.0-alpha.7", diff --git a/examples/pos-mainchain/src/app/app.ts b/examples/pos-mainchain/src/app/app.ts index d4c1f2407cb..ead5c491919 100644 --- a/examples/pos-mainchain/src/app/app.ts +++ b/examples/pos-mainchain/src/app/app.ts @@ -1,9 +1,22 @@ -import { Application, PartialApplicationConfig } from 'lisk-sdk'; +import { + Application, + FeeModule, + MainchainInteroperabilityModule, + PartialApplicationConfig, + TokenModule, + NFTModule, +} from 'lisk-sdk'; import { registerModules } from './modules'; import { registerPlugins } from './plugins'; export const getApplication = (config: PartialApplicationConfig): Application => { const { app } = Application.defaultApplication(config, true); + const tokenModule = new TokenModule(); + const nftModule = new NFTModule(); + const feeModule = new FeeModule(); + const interoperabilityModule = new MainchainInteroperabilityModule(); + interoperabilityModule.registerInteroperableModule(nftModule); + nftModule.addDependencies(interoperabilityModule.method, feeModule.method, tokenModule.method); registerModules(app); registerPlugins(app); diff --git a/examples/pos-mainchain/src/app/modules.ts b/examples/pos-mainchain/src/app/modules.ts index d69352da8ae..f332892b447 100644 --- a/examples/pos-mainchain/src/app/modules.ts +++ b/examples/pos-mainchain/src/app/modules.ts @@ -1,4 +1,6 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ import { Application } from 'lisk-sdk'; +import { TestNftModule } from './modules/testNft/module'; -export const registerModules = (_app: Application): void => {}; +export const registerModules = (app: Application): void => { + app.registerModule(new TestNftModule()); +}; diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts new file mode 100644 index 00000000000..c49bf2e016c --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts @@ -0,0 +1,61 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { validator } from '@liskhq/lisk-validator'; +import { + BaseCommand, + CommandVerifyContext, + CommandExecuteContext, + VerificationResult, + VerifyStatus, + NFTMethod, +} from 'lisk-sdk'; +import { NFTAttributes, mintNftParamsSchema } from '../types'; + +interface Params { + address: Buffer; + collectionID: Buffer; + attributesArray: NFTAttributes[]; +} + +export class MintNftCommand extends BaseCommand { + public schema = mintNftParamsSchema; + private _nftMethod!: NFTMethod; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async verify(context: CommandVerifyContext): Promise { + const { params } = context; + + validator.validate(this.schema, params); + + return { + status: VerifyStatus.OK, + }; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.create( + context.getMethodContext(), + params.address, + params.collectionID, + params.attributesArray, + ); + } +} diff --git a/examples/pos-mainchain/src/app/modules/testNft/constants.ts b/examples/pos-mainchain/src/app/modules/testNft/constants.ts new file mode 100644 index 00000000000..59561a22073 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export const LENGTH_COLLECTION_ID = 4; +export const MIN_LENGTH_MODULE_NAME = 1; +export const MAX_LENGTH_MODULE_NAME = 32; diff --git a/examples/pos-mainchain/src/app/modules/testNft/endpoint.ts b/examples/pos-mainchain/src/app/modules/testNft/endpoint.ts new file mode 100644 index 00000000000..1d091013741 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/endpoint.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEndpoint } from 'lisk-sdk'; + +export class TestNftEndpoint extends BaseEndpoint {} diff --git a/examples/pos-mainchain/src/app/modules/testNft/method.ts b/examples/pos-mainchain/src/app/modules/testNft/method.ts new file mode 100644 index 00000000000..5bab789e7f1 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/method.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseMethod } from 'lisk-sdk'; + +export class TestNftMethod extends BaseMethod {} diff --git a/examples/pos-mainchain/src/app/modules/testNft/module.ts b/examples/pos-mainchain/src/app/modules/testNft/module.ts new file mode 100644 index 00000000000..cea623bd0e1 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/module.ts @@ -0,0 +1,51 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseModule, ModuleInitArgs, ModuleMetadata, NFTMethod } from 'lisk-sdk'; +import { TestNftEndpoint } from './endpoint'; +import { TestNftMethod } from './method'; +import { MintNftCommand } from './commands/mint_nft'; + +export class TestNftModule extends BaseModule { + public endpoint = new TestNftEndpoint(this.stores, this.offchainStores); + public method = new TestNftMethod(this.stores, this.events); + public mintNftCommand = new MintNftCommand(this.stores, this.events); + public commands = [this.mintNftCommand]; + + private _nftMethod!: NFTMethod; + + public addDependencies(nftMethod: NFTMethod) { + this._nftMethod = nftMethod; + } + + public metadata(): ModuleMetadata { + return { + ...this.baseMetadata(), + endpoints: [], + commands: this.commands.map(command => ({ + name: command.name, + params: command.schema, + })), + events: [], + assets: [], + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async init(_args: ModuleInitArgs) { + this.mintNftCommand.init({ + nftMethod: this._nftMethod, + }); + } +} diff --git a/examples/pos-mainchain/src/app/modules/testNft/types.ts b/examples/pos-mainchain/src/app/modules/testNft/types.ts new file mode 100644 index 00000000000..395c8fd1db5 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/types.ts @@ -0,0 +1,60 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { LENGTH_COLLECTION_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from './constants'; + +export interface NFTAttributes { + module: string; + attributes: Buffer; +} + +export const mintNftParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['nftID', 'recipientAddress', 'data'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; diff --git a/framework/src/application.ts b/framework/src/application.ts index 20a01d59eb5..e91be0fecd0 100644 --- a/framework/src/application.ts +++ b/framework/src/application.ts @@ -55,7 +55,6 @@ import { } from './modules/interoperability'; import { DynamicRewardMethod, DynamicRewardModule } from './modules/dynamic_rewards'; import { Engine } from './engine'; -import { NFTMethod, NFTModule } from './modules/nft'; const isPidRunning = async (pid: number): Promise => psList().then(list => list.some(x => x.pid === pid)); @@ -109,7 +108,6 @@ interface DefaultApplication { validator: ValidatorsMethod; auth: AuthMethod; token: TokenMethod; - nft: NFTMethod; fee: FeeMethod; random: RandomMethod; reward: DynamicRewardMethod; @@ -165,7 +163,6 @@ export class Application { // create module instances const authModule = new AuthModule(); const tokenModule = new TokenModule(); - const nftModule = new NFTModule(); const feeModule = new FeeModule(); const rewardModule = new DynamicRewardModule(); const randomModule = new RandomModule(); @@ -195,11 +192,9 @@ export class Application { feeModule.method, ); tokenModule.addDependencies(interoperabilityModule.method, feeModule.method); - nftModule.addDependencies(interoperabilityModule.method, feeModule.method, tokenModule.method); // resolve interoperability dependencies interoperabilityModule.registerInteroperableModule(tokenModule); - interoperabilityModule.registerInteroperableModule(nftModule); interoperabilityModule.registerInteroperableModule(feeModule); // register modules @@ -207,7 +202,6 @@ export class Application { application._registerModule(authModule); application._registerModule(validatorModule); application._registerModule(tokenModule); - application._registerModule(nftModule); application._registerModule(rewardModule); application._registerModule(randomModule); application._registerModule(posModule); @@ -218,7 +212,6 @@ export class Application { method: { validator: validatorModule.method, token: tokenModule.method, - nft: nftModule.method, auth: authModule.method, fee: feeModule.method, pos: posModule.method, diff --git a/framework/src/index.ts b/framework/src/index.ts index 0d35d808c97..24d61b6e2cc 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -67,6 +67,7 @@ export { genesisTokenStoreSchema as tokenGenesisStoreSchema, CROSS_CHAIN_COMMAND_NAME_TRANSFER, } from './modules/token'; +export { NFTModule, NFTMethod } from './modules/nft'; export { PoSMethod, PoSModule, From 24545623fa4a253484cf214dad68c6a2ef7e5bef Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Wed, 28 Jun 2023 19:57:59 +0200 Subject: [PATCH 39/58] Init nft module --- .../nft/commands/transfer_cross_chain.ts | 3 +-- framework/src/modules/nft/module.ts | 25 ++++++++++++++++--- framework/src/modules/nft/types.ts | 3 +-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/framework/src/modules/nft/commands/transfer_cross_chain.ts b/framework/src/modules/nft/commands/transfer_cross_chain.ts index fad28836ca6..2dcc3ea7575 100644 --- a/framework/src/modules/nft/commands/transfer_cross_chain.ts +++ b/framework/src/modules/nft/commands/transfer_cross_chain.ts @@ -17,8 +17,7 @@ import { crossChainTransferParamsSchema } from '../schemas'; import { NFTStore } from '../stores/nft'; import { NFTMethod } from '../method'; import { LENGTH_CHAIN_ID, NFT_NOT_LOCKED } from '../constants'; -import { TokenMethod } from '../../token'; -import { InteroperabilityMethod } from '../types'; +import { InteroperabilityMethod, TokenMethod } from '../types'; import { BaseCommand } from '../../base_command'; import { CommandExecuteContext, diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 019d87642b8..0c7e7f7c657 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -81,10 +81,11 @@ export class NFTModule extends BaseInteroperableModule { private readonly _ccTransferCommand = new TransferCrossChainCommand(this.stores, this.events); private readonly _internalMethod = new InternalMethod(this.stores, this.events); private _interoperabilityMethod!: InteroperabilityMethod; + private _feeMethod!: FeeMethod; + private _tokenMethod!: TokenMethod; public commands = [this._transferCommand, this._ccTransferCommand]; - // eslint-disable-next-line no-useless-constructor public constructor() { super(); this.events.register(TransferEvent, new TransferEvent(this.name)); @@ -127,6 +128,8 @@ export class NFTModule extends BaseInteroperableModule { tokenMethod: TokenMethod, ) { this._interoperabilityMethod = interoperabilityMethod; + this._feeMethod = feeMethod; + this._tokenMethod = tokenMethod; this.method.addDependencies( interoperabilityMethod, this._internalMethod, @@ -181,8 +184,24 @@ export class NFTModule extends BaseInteroperableModule { }; } - // eslint-disable-next-line @typescript-eslint/no-empty-function - public async init(_args: ModuleInitArgs) {} + // eslint-disable-next-line @typescript-eslint/require-await + public async init(args: ModuleInitArgs) { + const ownChainID = Buffer.from(args.genesisConfig.chainID, 'hex'); + this._internalMethod.init({ ownChainID }); + this.method.init({ ownChainID }); + this.crossChainTransferCommand.init({ + method: this.method, + internalMethod: this._internalMethod, + feeMethod: this._feeMethod, + }); + + this._ccTransferCommand.init({ + internalMethod: this._internalMethod, + interoperabilityMethod: this._interoperabilityMethod, + nftMethod: this.method, + tokenMethod: this._tokenMethod, + }); + } public async initGenesisState(context: GenesisBlockExecuteContext): Promise { const assetBytes = context.assets.getAsset(this.name); diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 8b1647d67c2..173ff3d1227 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -15,7 +15,6 @@ import { ImmutableMethodContext, MethodContext } from '../../state_machine'; import { CCMsg } from '../interoperability'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ModuleConfig { ownChainID: Buffer; } @@ -43,7 +42,7 @@ export interface FeeMethod { export interface TokenMethod { getAvailableBalance( - methodContext: MethodContext, + methodContext: ImmutableMethodContext, address: Buffer, tokenID: Buffer, ): Promise; From 0f30c3ddd52e554f4aaaf80560d123c31a32e3f4 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 29 Jun 2023 13:11:37 +0200 Subject: [PATCH 40/58] Remove param validation --- .../app/modules/testNft/commands/mint_nft.ts | 24 +---------- .../src/app/modules/testNft/types.ts | 42 ------------------- 2 files changed, 2 insertions(+), 64 deletions(-) diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts index c49bf2e016c..f3d73c4571b 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts @@ -12,16 +12,8 @@ * Removal or modification of this copyright notice is prohibited. */ -import { validator } from '@liskhq/lisk-validator'; -import { - BaseCommand, - CommandVerifyContext, - CommandExecuteContext, - VerificationResult, - VerifyStatus, - NFTMethod, -} from 'lisk-sdk'; -import { NFTAttributes, mintNftParamsSchema } from '../types'; +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { NFTAttributes } from '../types'; interface Params { address: Buffer; @@ -30,24 +22,12 @@ interface Params { } export class MintNftCommand extends BaseCommand { - public schema = mintNftParamsSchema; private _nftMethod!: NFTMethod; public init(args: { nftMethod: NFTMethod }): void { this._nftMethod = args.nftMethod; } - // eslint-disable-next-line @typescript-eslint/require-await - public async verify(context: CommandVerifyContext): Promise { - const { params } = context; - - validator.validate(this.schema, params); - - return { - status: VerifyStatus.OK, - }; - } - public async execute(context: CommandExecuteContext): Promise { const { params } = context; diff --git a/examples/pos-mainchain/src/app/modules/testNft/types.ts b/examples/pos-mainchain/src/app/modules/testNft/types.ts index 395c8fd1db5..8d1af2d969a 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/types.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/types.ts @@ -12,49 +12,7 @@ * Removal or modification of this copyright notice is prohibited. */ -import { LENGTH_COLLECTION_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME } from './constants'; - export interface NFTAttributes { module: string; attributes: Buffer; } - -export const mintNftParamsSchema = { - $id: '/lisk/nftTransferParams', - type: 'object', - required: ['nftID', 'recipientAddress', 'data'], - properties: { - address: { - dataType: 'bytes', - format: 'lisk32', - fieldNumber: 1, - }, - collectionID: { - dataType: 'bytes', - minLength: LENGTH_COLLECTION_ID, - maxLength: LENGTH_COLLECTION_ID, - fieldNumber: 2, - }, - attributesArray: { - type: 'array', - fieldNumber: 4, - items: { - type: 'object', - required: ['module', 'attributes'], - properties: { - module: { - dataType: 'string', - minLength: MIN_LENGTH_MODULE_NAME, - maxLength: MAX_LENGTH_MODULE_NAME, - pattern: '^[a-zA-Z0-9]*$', - fieldNumber: 1, - }, - attributes: { - dataType: 'bytes', - fieldNumber: 2, - }, - }, - }, - }, - }, -}; From e98e2c3fdbb040dc912af9d44c88bc428b494fc9 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 30 Jun 2023 13:20:36 +0200 Subject: [PATCH 41/58] Update schema per feedback --- examples/pos-mainchain/config/default/genesis_assets.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/pos-mainchain/config/default/genesis_assets.json b/examples/pos-mainchain/config/default/genesis_assets.json index dd8d2b096fc..b92a196593d 100644 --- a/examples/pos-mainchain/config/default/genesis_assets.json +++ b/examples/pos-mainchain/config/default/genesis_assets.json @@ -1079,7 +1079,7 @@ "module": { "dataType": "string", "minLength": 1, - "maxLength": 1, + "maxLength": 32, "pattern": "^[a-zA-Z0-9]*$", "fieldNumber": 1 }, @@ -1102,6 +1102,8 @@ "properties": { "chainID": { "dataType": "bytes", + "minLength": 4, + "maxLength": 4, "fieldNumber": 1 }, "supportedCollectionIDArray": { From 0175d2bc4e6afe5e2be29a571f2dcc9ca50232f1 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 6 Jul 2023 00:47:12 +0200 Subject: [PATCH 42/58] Update per feedback --- examples/pos-mainchain/src/app/app.ts | 30 ++++----- .../modules/testNft/commands/destroy_nft.ts | 36 ++++++++++ .../app/modules/testNft/commands/mint_nft.ts | 3 +- .../src/app/modules/testNft/constants.ts | 1 + .../src/app/modules/testNft/types.ts | 66 +++++++++++++++++++ 5 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts diff --git a/examples/pos-mainchain/src/app/app.ts b/examples/pos-mainchain/src/app/app.ts index ead5c491919..291b8987d12 100644 --- a/examples/pos-mainchain/src/app/app.ts +++ b/examples/pos-mainchain/src/app/app.ts @@ -1,24 +1,20 @@ -import { - Application, - FeeModule, - MainchainInteroperabilityModule, - PartialApplicationConfig, - TokenModule, - NFTModule, -} from 'lisk-sdk'; -import { registerModules } from './modules'; -import { registerPlugins } from './plugins'; +import { Application, PartialApplicationConfig, NFTModule } from 'lisk-sdk'; +import { TestNftModule } from './modules/testNft/module'; export const getApplication = (config: PartialApplicationConfig): Application => { - const { app } = Application.defaultApplication(config, true); - const tokenModule = new TokenModule(); + const { app, method } = Application.defaultApplication(config, true); const nftModule = new NFTModule(); - const feeModule = new FeeModule(); - const interoperabilityModule = new MainchainInteroperabilityModule(); + const testNftModule = new TestNftModule(); + // eslint-disable-next-line @typescript-eslint/dot-notation + const interoperabilityModule = app['_registeredModules'].find( + mod => mod.name === 'interoperability', + ); interoperabilityModule.registerInteroperableModule(nftModule); - nftModule.addDependencies(interoperabilityModule.method, feeModule.method, tokenModule.method); - registerModules(app); - registerPlugins(app); + nftModule.addDependencies(method.interoperability, method.fee, method.token); + testNftModule.addDependencies(nftModule.method); + + app.registerModule(nftModule); + app.registerModule(testNftModule); return app; }; diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts new file mode 100644 index 00000000000..59f56628175 --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { destroyNftParamsSchema } from '../types'; + +interface Params { + address: Buffer; + nftID: Buffer; +} + +export class DestroyNftCommand extends BaseCommand { + private _nftMethod!: NFTMethod; + public schema = destroyNftParamsSchema; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.destroy(context.getMethodContext(), params.address, params.nftID); + } +} diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts index f3d73c4571b..b6e425c5198 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts @@ -13,7 +13,7 @@ */ import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; -import { NFTAttributes } from '../types'; +import { NFTAttributes, mintNftParamsSchema } from '../types'; interface Params { address: Buffer; @@ -23,6 +23,7 @@ interface Params { export class MintNftCommand extends BaseCommand { private _nftMethod!: NFTMethod; + public schema = mintNftParamsSchema; public init(args: { nftMethod: NFTMethod }): void { this._nftMethod = args.nftMethod; diff --git a/examples/pos-mainchain/src/app/modules/testNft/constants.ts b/examples/pos-mainchain/src/app/modules/testNft/constants.ts index 59561a22073..a0150fad36f 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/constants.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/constants.ts @@ -15,3 +15,4 @@ export const LENGTH_COLLECTION_ID = 4; export const MIN_LENGTH_MODULE_NAME = 1; export const MAX_LENGTH_MODULE_NAME = 32; +export const LENGTH_NFT_ID = 16; diff --git a/examples/pos-mainchain/src/app/modules/testNft/types.ts b/examples/pos-mainchain/src/app/modules/testNft/types.ts index 8d1af2d969a..a591883b9f0 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/types.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/types.ts @@ -12,7 +12,73 @@ * Removal or modification of this copyright notice is prohibited. */ +import { + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, +} from './constants'; + export interface NFTAttributes { module: string; attributes: Buffer; } + +export const mintNftParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['address', 'collectionID', 'attributesArray'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; + +export const destroyNftParamsSchema = { + $id: '/lisk/nftDestroyParams', + type: 'object', + required: ['address', 'nftID'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + }, +}; From 462e2b7496e5bccf1e78ddc940345b717d4c5ced Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:10:14 +0200 Subject: [PATCH 43/58] Skip lint check for examples app --- examples/pos-mainchain/.eslintignore | 1 + examples/pos-mainchain/src/app/app.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/pos-mainchain/.eslintignore b/examples/pos-mainchain/.eslintignore index 00a15e70c20..eee8bf98e19 100644 --- a/examples/pos-mainchain/.eslintignore +++ b/examples/pos-mainchain/.eslintignore @@ -12,3 +12,4 @@ scripts config test/_setup.js ecosystem.config.js +src/app/app.ts diff --git a/examples/pos-mainchain/src/app/app.ts b/examples/pos-mainchain/src/app/app.ts index 291b8987d12..6a99bf61049 100644 --- a/examples/pos-mainchain/src/app/app.ts +++ b/examples/pos-mainchain/src/app/app.ts @@ -5,7 +5,6 @@ export const getApplication = (config: PartialApplicationConfig): Application => const { app, method } = Application.defaultApplication(config, true); const nftModule = new NFTModule(); const testNftModule = new TestNftModule(); - // eslint-disable-next-line @typescript-eslint/dot-notation const interoperabilityModule = app['_registeredModules'].find( mod => mod.name === 'interoperability', ); From 2b04b3613f1c83d08a99d08a3fad2913076dc309 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 6 Jul 2023 11:44:44 +0200 Subject: [PATCH 44/58] Init transfer command --- framework/src/modules/nft/module.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 0c7e7f7c657..619f8cd4133 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -201,6 +201,7 @@ export class NFTModule extends BaseInteroperableModule { nftMethod: this.method, tokenMethod: this._tokenMethod, }); + this._transferCommand.init({ method: this.method, internalMethod: this._internalMethod }); } public async initGenesisState(context: GenesisBlockExecuteContext): Promise { From 6ab91007c3d43de2ea9c89ff5f0cd057a40f01d2 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 6 Jul 2023 13:07:09 +0200 Subject: [PATCH 45/58] Init destroy command --- examples/pos-mainchain/src/app/modules/testNft/module.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/pos-mainchain/src/app/modules/testNft/module.ts b/examples/pos-mainchain/src/app/modules/testNft/module.ts index cea623bd0e1..a228abff3af 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/module.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/module.ts @@ -16,12 +16,14 @@ import { BaseModule, ModuleInitArgs, ModuleMetadata, NFTMethod } from 'lisk-sdk' import { TestNftEndpoint } from './endpoint'; import { TestNftMethod } from './method'; import { MintNftCommand } from './commands/mint_nft'; +import { DestroyNftCommand } from './commands/destroy_nft'; export class TestNftModule extends BaseModule { public endpoint = new TestNftEndpoint(this.stores, this.offchainStores); public method = new TestNftMethod(this.stores, this.events); public mintNftCommand = new MintNftCommand(this.stores, this.events); - public commands = [this.mintNftCommand]; + public destroyNftCommand = new DestroyNftCommand(this.stores, this.events); + public commands = [this.mintNftCommand, this.destroyNftCommand]; private _nftMethod!: NFTMethod; @@ -47,5 +49,8 @@ export class TestNftModule extends BaseModule { this.mintNftCommand.init({ nftMethod: this._nftMethod, }); + this.destroyNftCommand.init({ + nftMethod: this._nftMethod, + }); } } From 906103a2236c07fff6d2521b7d6e680097660714 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:09:33 +0200 Subject: [PATCH 46/58] NFTMethod.create generates nftID of length LENGTH_NFT_ID (#8692) :bug: NFTMethod.create generates nftID of length LENGTH_NFT_ID --- framework/src/modules/nft/method.ts | 5 ++++- framework/test/unit/modules/nft/method.spec.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 3039737a15d..aba6cc6da31 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -310,10 +310,13 @@ export class NFTMethod extends BaseMethod { } const index = await this.getNextAvailableIndex(methodContext, collectionID); + const indexBytes = Buffer.from(index.toString()); + const nftID = Buffer.concat([ this._config.ownChainID, collectionID, - Buffer.from(index.toString()), + Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - indexBytes.length, 0), + indexBytes, ]); this._feeMethod.payFee(methodContext, BigInt(FEE_CREATE_NFT)); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index d5bcd5a9c07..23c46664d09 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -574,7 +574,13 @@ describe('NFTMethod', () => { }); it('should set data to stores with correct key and emit successfull create event when there is no entry in the nft substore', async () => { - const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('0')]); + const index = Buffer.from('0'); + const expectedKey = Buffer.concat([ + config.ownChainID, + collectionID, + Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - index.length, 0), + index, + ]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); @@ -594,6 +600,7 @@ describe('NFTMethod', () => { }); it('should set data to stores with correct key and emit successfull create event when there is some entry in the nft substore', async () => { + const index = Buffer.from('2'); await nftStore.save(methodContext, nftID, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), attributesArray: attributesArray1, @@ -603,7 +610,12 @@ describe('NFTMethod', () => { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), attributesArray: attributesArray2, }); - const expectedKey = Buffer.concat([config.ownChainID, collectionID, Buffer.from('2')]); + const expectedKey = Buffer.concat([ + config.ownChainID, + collectionID, + Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - index.length, 0), + index, + ]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); From c2aa925ac28f4bdce9397850abe5c9635832c4d4 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 7 Jul 2023 09:07:29 +0200 Subject: [PATCH 47/58] Move relevant types to schema --- .../modules/testNft/commands/destroy_nft.ts | 2 +- .../app/modules/testNft/commands/mint_nft.ts | 3 +- .../src/app/modules/testNft/schema.ts | 79 +++++++++++++++++++ .../src/app/modules/testNft/types.ts | 66 ---------------- 4 files changed, 82 insertions(+), 68 deletions(-) create mode 100644 examples/pos-mainchain/src/app/modules/testNft/schema.ts diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts index 59f56628175..822ad1b174f 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/destroy_nft.ts @@ -13,7 +13,7 @@ */ import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; -import { destroyNftParamsSchema } from '../types'; +import { destroyNftParamsSchema } from '../schema'; interface Params { address: Buffer; diff --git a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts index b6e425c5198..bc5638846d4 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/commands/mint_nft.ts @@ -13,7 +13,8 @@ */ import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; -import { NFTAttributes, mintNftParamsSchema } from '../types'; +import { NFTAttributes } from '../types'; +import { mintNftParamsSchema } from '../schema'; interface Params { address: Buffer; diff --git a/examples/pos-mainchain/src/app/modules/testNft/schema.ts b/examples/pos-mainchain/src/app/modules/testNft/schema.ts new file mode 100644 index 00000000000..c183e9fb8ff --- /dev/null +++ b/examples/pos-mainchain/src/app/modules/testNft/schema.ts @@ -0,0 +1,79 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, +} from './constants'; + +export const mintNftParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['address', 'collectionID', 'attributesArray'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; + +export const destroyNftParamsSchema = { + $id: '/lisk/nftDestroyParams', + type: 'object', + required: ['address', 'nftID'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + }, +}; diff --git a/examples/pos-mainchain/src/app/modules/testNft/types.ts b/examples/pos-mainchain/src/app/modules/testNft/types.ts index a591883b9f0..8d1af2d969a 100644 --- a/examples/pos-mainchain/src/app/modules/testNft/types.ts +++ b/examples/pos-mainchain/src/app/modules/testNft/types.ts @@ -12,73 +12,7 @@ * Removal or modification of this copyright notice is prohibited. */ -import { - LENGTH_COLLECTION_ID, - LENGTH_NFT_ID, - MAX_LENGTH_MODULE_NAME, - MIN_LENGTH_MODULE_NAME, -} from './constants'; - export interface NFTAttributes { module: string; attributes: Buffer; } - -export const mintNftParamsSchema = { - $id: '/lisk/nftTransferParams', - type: 'object', - required: ['address', 'collectionID', 'attributesArray'], - properties: { - address: { - dataType: 'bytes', - format: 'lisk32', - fieldNumber: 1, - }, - collectionID: { - dataType: 'bytes', - minLength: LENGTH_COLLECTION_ID, - maxLength: LENGTH_COLLECTION_ID, - fieldNumber: 2, - }, - attributesArray: { - type: 'array', - fieldNumber: 4, - items: { - type: 'object', - required: ['module', 'attributes'], - properties: { - module: { - dataType: 'string', - minLength: MIN_LENGTH_MODULE_NAME, - maxLength: MAX_LENGTH_MODULE_NAME, - pattern: '^[a-zA-Z0-9]*$', - fieldNumber: 1, - }, - attributes: { - dataType: 'bytes', - fieldNumber: 2, - }, - }, - }, - }, - }, -}; - -export const destroyNftParamsSchema = { - $id: '/lisk/nftDestroyParams', - type: 'object', - required: ['address', 'nftID'], - properties: { - address: { - dataType: 'bytes', - format: 'lisk32', - fieldNumber: 1, - }, - nftID: { - dataType: 'bytes', - minLength: LENGTH_NFT_ID, - maxLength: LENGTH_NFT_ID, - fieldNumber: 2, - }, - }, -}; From 6b7c91721ceae037011014c70b0ea3574694a2a8 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 7 Jul 2023 11:48:04 +0200 Subject: [PATCH 48/58] Adds NFTModule.name property (#8673) :seedling: Adds NFTModule.name property --- framework/src/modules/nft/module.ts | 4 ++++ framework/test/unit/modules/nft/module.spec.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 619f8cd4133..f925126ac78 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -122,6 +122,10 @@ export class NFTModule extends BaseInteroperableModule { this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); } + public get name(): string { + return 'nft'; + } + public addDependencies( interoperabilityMethod: InteroperabilityMethod, feeMethod: FeeMethod, diff --git a/framework/test/unit/modules/nft/module.spec.ts b/framework/test/unit/modules/nft/module.spec.ts index 397124fa95b..99ae42a0dd3 100644 --- a/framework/test/unit/modules/nft/module.spec.ts +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -54,6 +54,10 @@ describe('nft module', () => { return context; }; + it('should have the name "nft"', () => { + expect(module.name).toBe('nft'); + }); + describe('initGenesisState', () => { describe('validate nftSubstore schema', () => { it.each(invalidSchemaNFTSubstoreGenesisAssets)('%s', async (_, input, err) => { From 8a2dec0d48b9ce1f688717a6649e9686ce64475a Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:51:35 +0200 Subject: [PATCH 49/58] Fixes NFTEndpoint.getNFT for escrowed NFTs (#8716) * :bug: NFTMethod.getNFT for escrowed NFTs * :recycle: NFTEndpoint.getNFT --- framework/src/modules/nft/endpoint.ts | 32 ++++++++++++------- framework/src/modules/nft/module.ts | 1 + framework/src/modules/nft/types.ts | 2 +- .../test/unit/modules/nft/endpoint.spec.ts | 31 +++++++++++++++++- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/framework/src/modules/nft/endpoint.ts b/framework/src/modules/nft/endpoint.ts index 1999d881077..b41b4afd07d 100644 --- a/framework/src/modules/nft/endpoint.ts +++ b/framework/src/modules/nft/endpoint.ts @@ -26,7 +26,7 @@ import { isNFTSupportedRequestSchema, } from './schemas'; import { NFTStore } from './stores/nft'; -import { LENGTH_NFT_ID } from './constants'; +import { LENGTH_ADDRESS, LENGTH_NFT_ID } from './constants'; import { UserStore } from './stores/user'; import { NFT } from './types'; import { SupportedNFTsStore } from './stores/supported_nfts'; @@ -111,18 +111,28 @@ export class NFTEndpoint extends BaseEndpoint { const userStore = this.stores.get(UserStore); const nftData = await nftStore.get(context.getImmutableMethodContext(), nftID); - const userData = await userStore.get( - context.getImmutableMethodContext(), - userStore.getKey(nftData.owner, nftID), - ); + const owner = nftData.owner.toString('hex'); + const attributesArray = nftData.attributesArray.map(attribute => ({ + module: attribute.module, + attributes: attribute.attributes.toString('hex'), + })); + + if (nftData.owner.length === LENGTH_ADDRESS) { + const userData = await userStore.get( + context.getImmutableMethodContext(), + userStore.getKey(nftData.owner, nftID), + ); + + return { + owner, + attributesArray, + lockingModule: userData.lockingModule, + }; + } return { - owner: nftData.owner.toString('hex'), - attributesArray: nftData.attributesArray.map(attribute => ({ - module: attribute.module, - attributes: attribute.attributes.toString('hex'), - })), - lockingModule: userData.lockingModule, + owner, + attributesArray, }; } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index f925126ac78..cba0c0b2211 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -142,6 +142,7 @@ export class NFTModule extends BaseInteroperableModule { ); this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); this.crossChainMethod.addDependencies(interoperabilityMethod); + this.endpoint.addDependencies(this.method); } public metadata(): ModuleMetadata { diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index 173ff3d1227..d56e3343ebb 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -61,7 +61,7 @@ export interface NFTAttributes { export interface NFT { owner: string; attributesArray: NFTAttributes[]; - lockingModule: string; + lockingModule?: string; } export interface GenesisNFTStore { diff --git a/framework/test/unit/modules/nft/endpoint.spec.ts b/framework/test/unit/modules/nft/endpoint.spec.ts index d179db7dbeb..64fbabfe0d3 100644 --- a/framework/test/unit/modules/nft/endpoint.spec.ts +++ b/framework/test/unit/modules/nft/endpoint.spec.ts @@ -46,6 +46,7 @@ import { hasNFTResponseSchema, isNFTSupportedResponseSchema, } from '../../../../src/modules/nft/schemas'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; type NFTofOwner = Omit & { id: Buffer }; @@ -60,6 +61,7 @@ describe('NFTEndpoint', () => { const nftStore = module.stores.get(NFTStore); const userStore = module.stores.get(UserStore); + const escrowStore = module.stores.get(EscrowStore); const supportedNFTsStore = module.stores.get(SupportedNFTsStore); let stateStore: PrefixedStateReadWriter; @@ -67,6 +69,7 @@ describe('NFTEndpoint', () => { const owner = utils.getRandomBytes(LENGTH_ADDRESS); const ownerAddress = address.getLisk32AddressFromAddress(owner); + const escrowChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); const nfts: NFTofOwner[] = [ { @@ -100,7 +103,8 @@ describe('NFTEndpoint', () => { }); await userStore.set(methodContext, userStore.getKey(owner, nft.id), { - lockingModule: nft.lockingModule, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + lockingModule: nft.lockingModule!, }); } @@ -161,6 +165,31 @@ describe('NFTEndpoint', () => { validator.validate(getNFTsResponseSchema, expectedNFTs); }); + + it('should return NFT details for escrowed NFT', async () => { + await escrowStore.set(methodContext, escrowChainID, {}); + + await nftStore.save(methodContext, nfts[0].id, { + owner: escrowChainID, + attributesArray: [], + }); + + const context = createTransientModuleEndpointContext({ + stateStore, + params: { + id: nfts[0].id.toString('hex'), + }, + }); + + const expectedNFT: JSONObject = { + owner: escrowChainID.toString('hex'), + attributesArray: [], + }; + + await expect(endpoint.getNFT(context)).resolves.toEqual(expectedNFT); + + validator.validate(getNFTResponseSchema, expectedNFT); + }); }); describe('hasNFT', () => { From 5d68f3e781f8cdafaf6f244c7fa8d087a955a513 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 13 Jul 2023 14:41:30 +0200 Subject: [PATCH 50/58] Add nft to interop example app --- .../interop/pos-mainchain-fast/.eslintignore | 1 + .../interop/pos-mainchain-fast/src/app/app.ts | 17 +++- .../modules/testNft/commands/destroy_nft.ts | 36 +++++++++ .../app/modules/testNft/commands/mint_nft.ts | 43 ++++++++++ .../src/app/modules/testNft/constants.ts | 18 +++++ .../src/app/modules/testNft/endpoint.ts | 17 ++++ .../src/app/modules/testNft/method.ts | 17 ++++ .../src/app/modules/testNft/module.ts | 56 +++++++++++++ .../src/app/modules/testNft/schema.ts | 79 +++++++++++++++++++ .../src/app/modules/testNft/types.ts | 18 +++++ .../pos-sidechain-example-one/.eslintignore | 1 + .../pos-sidechain-example-one/src/app/app.ts | 17 +++- .../modules/testNft/commands/destroy_nft.ts | 36 +++++++++ .../app/modules/testNft/commands/mint_nft.ts | 43 ++++++++++ .../src/app/modules/testNft/constants.ts | 18 +++++ .../src/app/modules/testNft/endpoint.ts | 17 ++++ .../src/app/modules/testNft/method.ts | 17 ++++ .../src/app/modules/testNft/module.ts | 56 +++++++++++++ .../src/app/modules/testNft/schema.ts | 79 +++++++++++++++++++ .../src/app/modules/testNft/types.ts | 18 +++++ examples/pos-mainchain/src/app/modules.ts | 6 +- 21 files changed, 602 insertions(+), 8 deletions(-) create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/destroy_nft.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/mint_nft.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/constants.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/endpoint.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/method.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/module.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/schema.ts create mode 100644 examples/interop/pos-mainchain-fast/src/app/modules/testNft/types.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/destroy_nft.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/mint_nft.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/constants.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/endpoint.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/method.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/module.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/schema.ts create mode 100644 examples/interop/pos-sidechain-example-one/src/app/modules/testNft/types.ts diff --git a/examples/interop/pos-mainchain-fast/.eslintignore b/examples/interop/pos-mainchain-fast/.eslintignore index 06500399046..f65e71dc66b 100644 --- a/examples/interop/pos-mainchain-fast/.eslintignore +++ b/examples/interop/pos-mainchain-fast/.eslintignore @@ -11,3 +11,4 @@ build scripts config test/_setup.js +src/app/app.ts diff --git a/examples/interop/pos-mainchain-fast/src/app/app.ts b/examples/interop/pos-mainchain-fast/src/app/app.ts index c506c018be8..3250d66e460 100644 --- a/examples/interop/pos-mainchain-fast/src/app/app.ts +++ b/examples/interop/pos-mainchain-fast/src/app/app.ts @@ -1,9 +1,22 @@ -import { Application, PartialApplicationConfig } from 'lisk-sdk'; +import { Application, PartialApplicationConfig, NFTModule } from 'lisk-sdk'; +import { TestNftModule } from './modules/testNft/module'; import { registerModules } from './modules'; import { registerPlugins } from './plugins'; export const getApplication = (config: PartialApplicationConfig): Application => { - const { app } = Application.defaultApplication(config, true); + const { app, method } = Application.defaultApplication(config, true); + + const nftModule = new NFTModule(); + const testNftModule = new TestNftModule(); + const interoperabilityModule = app['_registeredModules'].find( + mod => mod.name === 'interoperability', + ); + interoperabilityModule.registerInteroperableModule(nftModule); + nftModule.addDependencies(method.interoperability, method.fee, method.token); + testNftModule.addDependencies(nftModule.method); + + app.registerModule(nftModule); + app.registerModule(testNftModule); registerModules(app); registerPlugins(app); diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/destroy_nft.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/destroy_nft.ts new file mode 100644 index 00000000000..822ad1b174f --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/destroy_nft.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { destroyNftParamsSchema } from '../schema'; + +interface Params { + address: Buffer; + nftID: Buffer; +} + +export class DestroyNftCommand extends BaseCommand { + private _nftMethod!: NFTMethod; + public schema = destroyNftParamsSchema; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.destroy(context.getMethodContext(), params.address, params.nftID); + } +} diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/mint_nft.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/mint_nft.ts new file mode 100644 index 00000000000..bc5638846d4 --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/commands/mint_nft.ts @@ -0,0 +1,43 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { NFTAttributes } from '../types'; +import { mintNftParamsSchema } from '../schema'; + +interface Params { + address: Buffer; + collectionID: Buffer; + attributesArray: NFTAttributes[]; +} + +export class MintNftCommand extends BaseCommand { + private _nftMethod!: NFTMethod; + public schema = mintNftParamsSchema; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.create( + context.getMethodContext(), + params.address, + params.collectionID, + params.attributesArray, + ); + } +} diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/constants.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/constants.ts new file mode 100644 index 00000000000..a0150fad36f --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export const LENGTH_COLLECTION_ID = 4; +export const MIN_LENGTH_MODULE_NAME = 1; +export const MAX_LENGTH_MODULE_NAME = 32; +export const LENGTH_NFT_ID = 16; diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/endpoint.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/endpoint.ts new file mode 100644 index 00000000000..1d091013741 --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/endpoint.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEndpoint } from 'lisk-sdk'; + +export class TestNftEndpoint extends BaseEndpoint {} diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/method.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/method.ts new file mode 100644 index 00000000000..5bab789e7f1 --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/method.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseMethod } from 'lisk-sdk'; + +export class TestNftMethod extends BaseMethod {} diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/module.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/module.ts new file mode 100644 index 00000000000..a228abff3af --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/module.ts @@ -0,0 +1,56 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseModule, ModuleInitArgs, ModuleMetadata, NFTMethod } from 'lisk-sdk'; +import { TestNftEndpoint } from './endpoint'; +import { TestNftMethod } from './method'; +import { MintNftCommand } from './commands/mint_nft'; +import { DestroyNftCommand } from './commands/destroy_nft'; + +export class TestNftModule extends BaseModule { + public endpoint = new TestNftEndpoint(this.stores, this.offchainStores); + public method = new TestNftMethod(this.stores, this.events); + public mintNftCommand = new MintNftCommand(this.stores, this.events); + public destroyNftCommand = new DestroyNftCommand(this.stores, this.events); + public commands = [this.mintNftCommand, this.destroyNftCommand]; + + private _nftMethod!: NFTMethod; + + public addDependencies(nftMethod: NFTMethod) { + this._nftMethod = nftMethod; + } + + public metadata(): ModuleMetadata { + return { + ...this.baseMetadata(), + endpoints: [], + commands: this.commands.map(command => ({ + name: command.name, + params: command.schema, + })), + events: [], + assets: [], + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async init(_args: ModuleInitArgs) { + this.mintNftCommand.init({ + nftMethod: this._nftMethod, + }); + this.destroyNftCommand.init({ + nftMethod: this._nftMethod, + }); + } +} diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/schema.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/schema.ts new file mode 100644 index 00000000000..c183e9fb8ff --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/schema.ts @@ -0,0 +1,79 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, +} from './constants'; + +export const mintNftParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['address', 'collectionID', 'attributesArray'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; + +export const destroyNftParamsSchema = { + $id: '/lisk/nftDestroyParams', + type: 'object', + required: ['address', 'nftID'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + }, +}; diff --git a/examples/interop/pos-mainchain-fast/src/app/modules/testNft/types.ts b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/types.ts new file mode 100644 index 00000000000..8d1af2d969a --- /dev/null +++ b/examples/interop/pos-mainchain-fast/src/app/modules/testNft/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export interface NFTAttributes { + module: string; + attributes: Buffer; +} diff --git a/examples/interop/pos-sidechain-example-one/.eslintignore b/examples/interop/pos-sidechain-example-one/.eslintignore index 06500399046..f65e71dc66b 100644 --- a/examples/interop/pos-sidechain-example-one/.eslintignore +++ b/examples/interop/pos-sidechain-example-one/.eslintignore @@ -11,3 +11,4 @@ build scripts config test/_setup.js +src/app/app.ts diff --git a/examples/interop/pos-sidechain-example-one/src/app/app.ts b/examples/interop/pos-sidechain-example-one/src/app/app.ts index d9dc8b2ad28..3250d66e460 100644 --- a/examples/interop/pos-sidechain-example-one/src/app/app.ts +++ b/examples/interop/pos-sidechain-example-one/src/app/app.ts @@ -1,9 +1,22 @@ -import { Application, PartialApplicationConfig } from 'lisk-sdk'; +import { Application, PartialApplicationConfig, NFTModule } from 'lisk-sdk'; +import { TestNftModule } from './modules/testNft/module'; import { registerModules } from './modules'; import { registerPlugins } from './plugins'; export const getApplication = (config: PartialApplicationConfig): Application => { - const { app } = Application.defaultApplication(config); + const { app, method } = Application.defaultApplication(config, true); + + const nftModule = new NFTModule(); + const testNftModule = new TestNftModule(); + const interoperabilityModule = app['_registeredModules'].find( + mod => mod.name === 'interoperability', + ); + interoperabilityModule.registerInteroperableModule(nftModule); + nftModule.addDependencies(method.interoperability, method.fee, method.token); + testNftModule.addDependencies(nftModule.method); + + app.registerModule(nftModule); + app.registerModule(testNftModule); registerModules(app); registerPlugins(app); diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/destroy_nft.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/destroy_nft.ts new file mode 100644 index 00000000000..822ad1b174f --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/destroy_nft.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { destroyNftParamsSchema } from '../schema'; + +interface Params { + address: Buffer; + nftID: Buffer; +} + +export class DestroyNftCommand extends BaseCommand { + private _nftMethod!: NFTMethod; + public schema = destroyNftParamsSchema; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.destroy(context.getMethodContext(), params.address, params.nftID); + } +} diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/mint_nft.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/mint_nft.ts new file mode 100644 index 00000000000..bc5638846d4 --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/commands/mint_nft.ts @@ -0,0 +1,43 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseCommand, CommandExecuteContext, NFTMethod } from 'lisk-sdk'; +import { NFTAttributes } from '../types'; +import { mintNftParamsSchema } from '../schema'; + +interface Params { + address: Buffer; + collectionID: Buffer; + attributesArray: NFTAttributes[]; +} + +export class MintNftCommand extends BaseCommand { + private _nftMethod!: NFTMethod; + public schema = mintNftParamsSchema; + + public init(args: { nftMethod: NFTMethod }): void { + this._nftMethod = args.nftMethod; + } + + public async execute(context: CommandExecuteContext): Promise { + const { params } = context; + + await this._nftMethod.create( + context.getMethodContext(), + params.address, + params.collectionID, + params.attributesArray, + ); + } +} diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/constants.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/constants.ts new file mode 100644 index 00000000000..a0150fad36f --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export const LENGTH_COLLECTION_ID = 4; +export const MIN_LENGTH_MODULE_NAME = 1; +export const MAX_LENGTH_MODULE_NAME = 32; +export const LENGTH_NFT_ID = 16; diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/endpoint.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/endpoint.ts new file mode 100644 index 00000000000..1d091013741 --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/endpoint.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseEndpoint } from 'lisk-sdk'; + +export class TestNftEndpoint extends BaseEndpoint {} diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/method.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/method.ts new file mode 100644 index 00000000000..5bab789e7f1 --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/method.ts @@ -0,0 +1,17 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseMethod } from 'lisk-sdk'; + +export class TestNftMethod extends BaseMethod {} diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/module.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/module.ts new file mode 100644 index 00000000000..a228abff3af --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/module.ts @@ -0,0 +1,56 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { BaseModule, ModuleInitArgs, ModuleMetadata, NFTMethod } from 'lisk-sdk'; +import { TestNftEndpoint } from './endpoint'; +import { TestNftMethod } from './method'; +import { MintNftCommand } from './commands/mint_nft'; +import { DestroyNftCommand } from './commands/destroy_nft'; + +export class TestNftModule extends BaseModule { + public endpoint = new TestNftEndpoint(this.stores, this.offchainStores); + public method = new TestNftMethod(this.stores, this.events); + public mintNftCommand = new MintNftCommand(this.stores, this.events); + public destroyNftCommand = new DestroyNftCommand(this.stores, this.events); + public commands = [this.mintNftCommand, this.destroyNftCommand]; + + private _nftMethod!: NFTMethod; + + public addDependencies(nftMethod: NFTMethod) { + this._nftMethod = nftMethod; + } + + public metadata(): ModuleMetadata { + return { + ...this.baseMetadata(), + endpoints: [], + commands: this.commands.map(command => ({ + name: command.name, + params: command.schema, + })), + events: [], + assets: [], + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async init(_args: ModuleInitArgs) { + this.mintNftCommand.init({ + nftMethod: this._nftMethod, + }); + this.destroyNftCommand.init({ + nftMethod: this._nftMethod, + }); + } +} diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/schema.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/schema.ts new file mode 100644 index 00000000000..c183e9fb8ff --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/schema.ts @@ -0,0 +1,79 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, +} from './constants'; + +export const mintNftParamsSchema = { + $id: '/lisk/nftTransferParams', + type: 'object', + required: ['address', 'collectionID', 'attributesArray'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 2, + }, + attributesArray: { + type: 'array', + fieldNumber: 4, + items: { + type: 'object', + required: ['module', 'attributes'], + properties: { + module: { + dataType: 'string', + minLength: MIN_LENGTH_MODULE_NAME, + maxLength: MAX_LENGTH_MODULE_NAME, + pattern: '^[a-zA-Z0-9]*$', + fieldNumber: 1, + }, + attributes: { + dataType: 'bytes', + fieldNumber: 2, + }, + }, + }, + }, + }, +}; + +export const destroyNftParamsSchema = { + $id: '/lisk/nftDestroyParams', + type: 'object', + required: ['address', 'nftID'], + properties: { + address: { + dataType: 'bytes', + format: 'lisk32', + fieldNumber: 1, + }, + nftID: { + dataType: 'bytes', + minLength: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + }, +}; diff --git a/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/types.ts b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/types.ts new file mode 100644 index 00000000000..8d1af2d969a --- /dev/null +++ b/examples/interop/pos-sidechain-example-one/src/app/modules/testNft/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2023 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +export interface NFTAttributes { + module: string; + attributes: Buffer; +} diff --git a/examples/pos-mainchain/src/app/modules.ts b/examples/pos-mainchain/src/app/modules.ts index f332892b447..d69352da8ae 100644 --- a/examples/pos-mainchain/src/app/modules.ts +++ b/examples/pos-mainchain/src/app/modules.ts @@ -1,6 +1,4 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ import { Application } from 'lisk-sdk'; -import { TestNftModule } from './modules/testNft/module'; -export const registerModules = (app: Application): void => { - app.registerModule(new TestNftModule()); -}; +export const registerModules = (_app: Application): void => {}; From fb86ace9fbfbe536f78e9e1ecdbcc415ed7a05d1 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Thu, 20 Jul 2023 11:03:26 +0200 Subject: [PATCH 51/58] NFTModule's stores registration (#8762) :bug: Fixes indexes for NFTModule's store registration --- framework/src/modules/nft/module.ts | 8 ++++---- framework/test/unit/modules/nft/method.spec.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index cba0c0b2211..5a13661163f 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -116,10 +116,10 @@ export class NFTModule extends BaseInteroperableModule { AllNFTsFromCollectionSupportRemovedEvent, new AllNFTsFromCollectionSupportRemovedEvent(this.name), ); - this.stores.register(NFTStore, new NFTStore(this.name, 1)); - this.stores.register(UserStore, new UserStore(this.name, 2)); - this.stores.register(EscrowStore, new EscrowStore(this.name, 3)); - this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); + this.stores.register(NFTStore, new NFTStore(this.name, 0)); + this.stores.register(UserStore, new UserStore(this.name, 1)); + this.stores.register(EscrowStore, new EscrowStore(this.name, 2)); + this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 3)); } public get name(): string { diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 23c46664d09..4452dcb29d8 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -1606,7 +1606,7 @@ describe('NFTMethod', () => { describe('recover', () => { const terminatedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const substorePrefix = Buffer.from('8000', 'hex'); + const substorePrefix = Buffer.from('0000', 'hex'); const storeKey = utils.getRandomBytes(LENGTH_NFT_ID); const storeValue = codec.encode(nftStoreSchema, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), From a8fa079b5a12a45a3153e48c8eaa42f87a81c93f Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 21 Jul 2023 16:18:27 +0200 Subject: [PATCH 52/58] NFTMethod.create uses integer as the index segment of NFT ID (#8725) :bug: NFTMethod.create uses integer as the index segment of NFT ID --- framework/src/modules/nft/method.ts | 10 +++------ .../test/unit/modules/nft/method.spec.ts | 22 +++++++------------ 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index aba6cc6da31..3e7c412b563 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -310,14 +310,10 @@ export class NFTMethod extends BaseMethod { } const index = await this.getNextAvailableIndex(methodContext, collectionID); - const indexBytes = Buffer.from(index.toString()); + const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); + indexBytes.writeBigInt64BE(BigInt(index)); - const nftID = Buffer.concat([ - this._config.ownChainID, - collectionID, - Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - indexBytes.length, 0), - indexBytes, - ]); + const nftID = Buffer.concat([this._config.ownChainID, collectionID, indexBytes]); this._feeMethod.payFee(methodContext, BigInt(FEE_CREATE_NFT)); const nftStore = this.stores.get(NFTStore); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 4452dcb29d8..c06f98f33c0 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -574,13 +574,10 @@ describe('NFTMethod', () => { }); it('should set data to stores with correct key and emit successfull create event when there is no entry in the nft substore', async () => { - const index = Buffer.from('0'); - const expectedKey = Buffer.concat([ - config.ownChainID, - collectionID, - Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - index.length, 0), - index, - ]); + const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); + indexBytes.writeBigInt64BE(BigInt(0)); + + const expectedKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); @@ -600,7 +597,9 @@ describe('NFTMethod', () => { }); it('should set data to stores with correct key and emit successfull create event when there is some entry in the nft substore', async () => { - const index = Buffer.from('2'); + const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); + indexBytes.writeBigInt64BE(BigInt(2)); + await nftStore.save(methodContext, nftID, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), attributesArray: attributesArray1, @@ -610,12 +609,7 @@ describe('NFTMethod', () => { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), attributesArray: attributesArray2, }); - const expectedKey = Buffer.concat([ - config.ownChainID, - collectionID, - Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID - index.length, 0), - index, - ]); + const expectedKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); await method.create(methodContext, address, collectionID, attributesArray3); const nftStoreData = await nftStore.get(methodContext, expectedKey); From 9902905c3b61f9e9579c92d7de50c863fba558e4 Mon Sep 17 00:00:00 2001 From: has5aan <50018215+has5aan@users.noreply.github.com> Date: Fri, 21 Jul 2023 17:52:23 +0200 Subject: [PATCH 53/58] NFTMethod.getNextAvailableIndex returns the highest index incremented by 1 within a collection (#8730) * :bug: NFTMethod.getNextAvailableIndex iterates over collections within ownChain * :recycle: Removes redundant counting logic in NFTStore.getNextAvailableIndex * :necktie: NFTMethod.getNextAvailableIndex increments the largest index within a collection of a chain * :recycle: NFTMethod.getNextAvailableIndex --- framework/src/modules/nft/method.ts | 28 +++-- .../test/unit/modules/nft/method.spec.ts | 103 ++++++++++-------- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 3e7c412b563..7571db37d66 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -278,21 +278,29 @@ export class NFTMethod extends BaseMethod { public async getNextAvailableIndex( methodContext: MethodContext, collectionID: Buffer, - ): Promise { + ): Promise { + const indexLength = LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID; const nftStore = this.stores.get(NFTStore); + const nftStoreData = await nftStore.iterate(methodContext, { - gte: Buffer.alloc(LENGTH_NFT_ID, 0), - lte: Buffer.alloc(LENGTH_NFT_ID, 255), + gte: Buffer.concat([this._config.ownChainID, collectionID, Buffer.alloc(indexLength, 0)]), + lte: Buffer.concat([this._config.ownChainID, collectionID, Buffer.alloc(indexLength, 255)]), }); - let count = 0; - for (const { key } of nftStoreData) { - if (key.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID).equals(collectionID)) { - count += 1; - } + if (nftStoreData.length === 0) { + return BigInt(0); + } + + const latestKey = nftStoreData[nftStoreData.length - 1].key; + const indexBytes = latestKey.slice(LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID, LENGTH_NFT_ID); + const index = indexBytes.readBigUInt64BE(); + const largestIndex = BigInt(BigInt(2 ** 64) - BigInt(1)); + + if (index === largestIndex) { + throw new Error('No more available indexes'); } - return count; + return index + BigInt(1); } public async create( @@ -311,7 +319,7 @@ export class NFTMethod extends BaseMethod { const index = await this.getNextAvailableIndex(methodContext, collectionID); const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); - indexBytes.writeBigInt64BE(BigInt(index)); + indexBytes.writeBigInt64BE(index); const nftID = Buffer.concat([this._config.ownChainID, collectionID, indexBytes]); this._feeMethod.payFee(methodContext, BigInt(FEE_CREATE_NFT)); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index c06f98f33c0..628ca3902ae 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -96,7 +96,13 @@ describe('NFTMethod', () => { const userStore = module.stores.get(UserStore); const supportedNFTsStore = module.stores.get(SupportedNFTsStore); - const nftID = utils.getRandomBytes(LENGTH_NFT_ID); + const firstIndex = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID, 0); + firstIndex.writeBigUInt64BE(BigInt(0)); + const nftID = Buffer.concat([ + config.ownChainID, + utils.getRandomBytes(LENGTH_CHAIN_ID), + firstIndex, + ]); let owner: Buffer; const checkEventResult = ( @@ -424,14 +430,20 @@ describe('NFTMethod', () => { }); it('should return false if nft chain id does not equal own chain id and nft chain id is supported but corresponding supported collection id array does not include collection id for nft id', async () => { - await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { + const foreignNFT = utils.getRandomBytes(LENGTH_NFT_ID); + await nftStore.save(methodContext, foreignNFT, { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }); + + await supportedNFTsStore.set(methodContext, foreignNFT.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, ], }); - const isSupported = await method.isNFTSupported(methodContext, nftID); + const isSupported = await method.isNFTSupported(methodContext, foreignNFT); expect(isSupported).toBe(false); }); }); @@ -494,27 +506,23 @@ describe('NFTMethod', () => { }); describe('getNextAvailableIndex', () => { - const attributesArray1 = [ + const attributesArray = [ { module: 'customMod1', attributes: Buffer.alloc(5) }, { module: 'customMod2', attributes: Buffer.alloc(2) }, ]; - const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); beforeEach(async () => { await nftStore.save(methodContext, nftID, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: attributesArray1, + attributesArray, }); }); - it('should return index count 0 if entry does not exist in the nft substore for the nft id', async () => { + it('should return index count 0 if there is no entry in nft substore', async () => { await nftStore.del(methodContext, nftID); - const returnedIndex = await method.getNextAvailableIndex( - methodContext, - utils.getRandomBytes(LENGTH_COLLECTION_ID), - ); - expect(returnedIndex).toBe(0); + const returnedIndex = await method.getNextAvailableIndex(methodContext, collectionID); + expect(returnedIndex).toBe(BigInt(0)); }); it('should return index count 0 if entry exists in the nft substore for the nft id and no key matches the given collection id', async () => { @@ -522,32 +530,40 @@ describe('NFTMethod', () => { methodContext, utils.getRandomBytes(LENGTH_COLLECTION_ID), ); - expect(returnedIndex).toBe(0); + expect(returnedIndex).toBe(BigInt(0)); }); - it('should return index count 1 if entry exists in the nft substore for the nft id and a key matches the given collection id', async () => { + it('should return existing highest index incremented by 1 within the given collection id', async () => { + const highestIndex = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID, 0); + highestIndex.writeBigUInt64BE(BigInt(419)); + const nftIDHighestIndex = Buffer.concat([config.ownChainID, collectionID, highestIndex]); + await nftStore.save(methodContext, nftIDHighestIndex, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray, + }); + const returnedIndex = await method.getNextAvailableIndex(methodContext, collectionID); - expect(returnedIndex).toBe(1); + expect(returnedIndex).toBe(BigInt(420)); }); - it('should return non zero index count if entry exists in the nft substore for the nft id and more than 1 key matches the given collection id', async () => { - const newKey = Buffer.concat([utils.getRandomBytes(LENGTH_CHAIN_ID), collectionID]); - await nftStore.save(methodContext, newKey, { + it('should throw if indexes within a collection are consumed', async () => { + const largestIndex = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID, 0); + largestIndex.writeBigUInt64BE(BigInt(BigInt(2 ** 64) - BigInt(1))); + const nftIDHighestIndex = Buffer.concat([config.ownChainID, collectionID, largestIndex]); + await nftStore.save(methodContext, nftIDHighestIndex, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: attributesArray2, + attributesArray, }); - const returnedIndex = await method.getNextAvailableIndex(methodContext, collectionID); - expect(returnedIndex).toBe(2); + + await expect(method.getNextAvailableIndex(methodContext, collectionID)).rejects.toThrow( + 'No more available indexes', + ); }); }); describe('create', () => { - const attributesArray1 = [ - { module: 'customMod1', attributes: Buffer.alloc(5) }, - { module: 'customMod2', attributes: Buffer.alloc(2) }, - ]; - const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; - const attributesArray3 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; + const attributesArray1 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; + const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); const address = utils.getRandomBytes(LENGTH_ADDRESS); @@ -579,7 +595,7 @@ describe('NFTMethod', () => { const expectedKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); - await method.create(methodContext, address, collectionID, attributesArray3); + await method.create(methodContext, address, collectionID, attributesArray2); const nftStoreData = await nftStore.get(methodContext, expectedKey); const userStoreData = await userStore.get( methodContext, @@ -587,7 +603,7 @@ describe('NFTMethod', () => { ); expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); expect(nftStoreData.owner).toStrictEqual(address); - expect(nftStoreData.attributesArray).toEqual(attributesArray3); + expect(nftStoreData.attributesArray).toEqual(attributesArray2); expect(userStoreData.lockingModule).toEqual(NFT_NOT_LOCKED); checkEventResult(methodContext.eventQueue, 1, CreateEvent, 0, { address, @@ -598,20 +614,20 @@ describe('NFTMethod', () => { it('should set data to stores with correct key and emit successfull create event when there is some entry in the nft substore', async () => { const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); - indexBytes.writeBigInt64BE(BigInt(2)); - - await nftStore.save(methodContext, nftID, { - owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: attributesArray1, - }); - const newKey = Buffer.concat([utils.getRandomBytes(LENGTH_CHAIN_ID), collectionID]); + indexBytes.writeBigUint64BE(BigInt(911)); + const newKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); await nftStore.save(methodContext, newKey, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: attributesArray2, + attributesArray: attributesArray1, }); - const expectedKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); - await method.create(methodContext, address, collectionID, attributesArray3); + const expectedIndexBytes = Buffer.alloc( + LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID, + ); + expectedIndexBytes.writeBigUint64BE(BigInt(912)); + const expectedKey = Buffer.concat([config.ownChainID, collectionID, expectedIndexBytes]); + + await method.create(methodContext, address, collectionID, attributesArray2); const nftStoreData = await nftStore.get(methodContext, expectedKey); const userStoreData = await userStore.get( methodContext, @@ -619,7 +635,7 @@ describe('NFTMethod', () => { ); expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); expect(nftStoreData.owner).toStrictEqual(address); - expect(nftStoreData.attributesArray).toEqual(attributesArray3); + expect(nftStoreData.attributesArray).toEqual(attributesArray2); expect(userStoreData.lockingModule).toEqual(NFT_NOT_LOCKED); checkEventResult(methodContext.eventQueue, 1, CreateEvent, 0, { address, @@ -936,13 +952,14 @@ describe('NFTMethod', () => { }); it('should throw and emit error transfer cross chain event if nft does not exist', async () => { - receivingChainID = nftID.slice(0, LENGTH_CHAIN_ID); + const nonExistingNFTID = utils.getRandomBytes(LENGTH_NFT_ID); + receivingChainID = nonExistingNFTID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, senderAddress, recipientAddress, - nftID, + nonExistingNFTID, receivingChainID, messageFee, data, @@ -958,7 +975,7 @@ describe('NFTMethod', () => { senderAddress, recipientAddress, receivingChainID, - nftID, + nftID: nonExistingNFTID, includeAttributes, }, NftEventResult.RESULT_NFT_DOES_NOT_EXIST, From f5948ed87c346c2ceb2c726619d984a8d234b20b Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 28 Jul 2023 09:35:02 +0100 Subject: [PATCH 54/58] Fix build on branch feature/6917-implement-nft-module (#8777) * Fix build * Update tests --- framework/src/modules/nft/internal_method.ts | 2 -- framework/src/modules/nft/module.ts | 3 +-- framework/src/modules/nft/types.ts | 1 - framework/test/unit/modules/nft/internal_method.spec.ts | 5 ----- 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index 6b87cbb7aea..95cd702c0de 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -22,7 +22,6 @@ import { UserStore } from './stores/user'; import { CROSS_CHAIN_COMMAND_NAME_TRANSFER, MODULE_NAME_NFT, NFT_NOT_LOCKED } from './constants'; import { EscrowStore } from './stores/escrow'; import { TransferCrossChainEvent } from './events/transfer_cross_chain'; -import { CCM_STATUS_OK } from '../token/constants'; import { crossChainNFTTransferMessageParamsSchema } from './schemas'; export class InternalMethod extends BaseMethod { @@ -166,7 +165,6 @@ export class InternalMethod extends BaseMethod { CROSS_CHAIN_COMMAND_NAME_TRANSFER, receivingChainID, messageFee, - CCM_STATUS_OK, codec.encode(crossChainNFTTransferMessageParamsSchema, { nftID, senderAddress, diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 5a13661163f..a772194a149 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -18,7 +18,7 @@ import { validator } from '@liskhq/lisk-validator'; import { GenesisBlockExecuteContext } from '../../state_machine'; import { ModuleInitArgs, ModuleMetadata } from '../base_module'; import { BaseInteroperableModule } from '../interoperability'; -import { InteroperabilityMethod } from '../token/types'; +import { InteroperabilityMethod, FeeMethod, GenesisNFTStore, TokenMethod } from './types'; import { NFTInteroperableMethod } from './cc_method'; import { NFTEndpoint } from './endpoint'; import { AllNFTsFromChainSupportedEvent } from './events/all_nfts_from_chain_suported'; @@ -59,7 +59,6 @@ import { EscrowStore } from './stores/escrow'; import { NFTStore } from './stores/nft'; import { SupportedNFTsStore } from './stores/supported_nfts'; import { UserStore } from './stores/user'; -import { FeeMethod, GenesisNFTStore, TokenMethod } from './types'; import { CrossChainTransferCommand as CrossChainTransferMessageCommand } from './cc_commands/cc_transfer'; import { TransferCrossChainCommand } from './commands/transfer_cross_chain'; import { TransferCommand } from './commands/transfer'; diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index d56e3343ebb..64ccefa1a54 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -27,7 +27,6 @@ export interface InteroperabilityMethod { crossChainCommand: string, receivingChainID: Buffer, fee: bigint, - status: number, parameters: Buffer, timestamp?: number, ): Promise; diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 4817a89d45d..72e158fa376 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -40,7 +40,6 @@ import { TransferCrossChainEventData, } from '../../../../src/modules/nft/events/transfer_cross_chain'; import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/events/destroy'; -import { CCM_STATUS_OK } from '../../../../src/modules/token/constants'; import { crossChainNFTTransferMessageParamsSchema } from '../../../../src/modules/nft/schemas'; describe('InternalMethod', () => { @@ -294,7 +293,6 @@ describe('InternalMethod', () => { CROSS_CHAIN_COMMAND_NAME_TRANSFER, receivingChainID, messageFee, - CCM_STATUS_OK, ccmParameters, ); }); @@ -363,7 +361,6 @@ describe('InternalMethod', () => { CROSS_CHAIN_COMMAND_NAME_TRANSFER, receivingChainID, messageFee, - CCM_STATUS_OK, ccmParameters, ); }); @@ -449,7 +446,6 @@ describe('InternalMethod', () => { CROSS_CHAIN_COMMAND_NAME_TRANSFER, receivingChainID, messageFee, - CCM_STATUS_OK, ccmParameters, ); }); @@ -525,7 +521,6 @@ describe('InternalMethod', () => { CROSS_CHAIN_COMMAND_NAME_TRANSFER, receivingChainID, messageFee, - CCM_STATUS_OK, ccmParameters, ); }); From 1aa07a6c25e2acd1460c12b5425c4f93e2a3e02d Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Tue, 1 Aug 2023 13:26:07 +0200 Subject: [PATCH 55/58] Add missing param --- .../src/modules/nft/commands/transfer_cross_chain.ts | 1 + framework/src/modules/nft/internal_method.ts | 2 ++ framework/test/unit/modules/nft/internal_method.spec.ts | 9 +++++++++ 3 files changed, 12 insertions(+) diff --git a/framework/src/modules/nft/commands/transfer_cross_chain.ts b/framework/src/modules/nft/commands/transfer_cross_chain.ts index 2dcc3ea7575..533dd83948e 100644 --- a/framework/src/modules/nft/commands/transfer_cross_chain.ts +++ b/framework/src/modules/nft/commands/transfer_cross_chain.ts @@ -134,6 +134,7 @@ export class TransferCrossChainCommand extends BaseCommand { params.messageFee, params.data, params.includeAttributes, + context.header.timestamp, ); } } diff --git a/framework/src/modules/nft/internal_method.ts b/framework/src/modules/nft/internal_method.ts index 95cd702c0de..bb27b9eac6a 100644 --- a/framework/src/modules/nft/internal_method.ts +++ b/framework/src/modules/nft/internal_method.ts @@ -116,6 +116,7 @@ export class InternalMethod extends BaseMethod { messageFee: bigint, data: string, includeAttributes: boolean, + timestamp?: number, ): Promise { const chainID = this._method.getChainID(nftID); const nftStore = this.stores.get(NFTStore); @@ -172,6 +173,7 @@ export class InternalMethod extends BaseMethod { attributesArray, data, }), + timestamp, ); } diff --git a/framework/test/unit/modules/nft/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts index 72e158fa376..b9a843e008e 100644 --- a/framework/test/unit/modules/nft/internal_method.spec.ts +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -205,6 +205,7 @@ describe('InternalMethod', () => { let receivingChainID: Buffer; const messageFee = BigInt(1000); const data = ''; + const timestamp = Math.floor(Date.now() / 1000); beforeEach(() => { receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); @@ -254,6 +255,7 @@ describe('InternalMethod', () => { messageFee, data, includeAttributes, + timestamp, ), ).resolves.toBeUndefined(); @@ -294,6 +296,7 @@ describe('InternalMethod', () => { receivingChainID, messageFee, ccmParameters, + timestamp, ); }); @@ -330,6 +333,7 @@ describe('InternalMethod', () => { messageFee, data, includeAttributes, + timestamp, ), ).resolves.toBeUndefined(); @@ -362,6 +366,7 @@ describe('InternalMethod', () => { receivingChainID, messageFee, ccmParameters, + timestamp, ); }); }); @@ -407,6 +412,7 @@ describe('InternalMethod', () => { messageFee, data, includeAttributes, + timestamp, ), ).resolves.toBeUndefined(); @@ -447,6 +453,7 @@ describe('InternalMethod', () => { receivingChainID, messageFee, ccmParameters, + timestamp, ); }); @@ -490,6 +497,7 @@ describe('InternalMethod', () => { messageFee, data, includeAttributes, + timestamp, ), ).resolves.toBeUndefined(); @@ -522,6 +530,7 @@ describe('InternalMethod', () => { receivingChainID, messageFee, ccmParameters, + timestamp, ); }); }); From fcc7b7b29389931630e860673c64822a3e615311 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 10 Aug 2023 17:10:09 +0100 Subject: [PATCH 56/58] Receiving chain incorrectly checks for the owner of nft when sending chain is not own chain (#8834) * Update verify hook * Add new test case * Remove redundant check --- .../modules/nft/cc_commands/cc_transfer.ts | 11 +++---- .../nft/cc_comands/cc_transfer.spec.ts | 32 ++++++++++++++++--- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts index ea61435c300..f20c72359b6 100644 --- a/framework/src/modules/nft/cc_commands/cc_transfer.ts +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -70,13 +70,12 @@ export class CrossChainTransferCommand extends BaseCCCommand { const nftStore = this.stores.get(NFTStore); const nftExists = await nftStore.has(getMethodContext(), nftID); - if (nftChainID.equals(ownChainID) && !nftExists) { - throw new Error('Non-existent entry in the NFT substore'); - } - const owner = await this._method.getNFTOwner(getMethodContext(), nftID); - if (nftChainID.equals(ownChainID) && !owner.equals(sendingChainID)) { - throw new Error('NFT has not been properly escrowed'); + if (nftChainID.equals(ownChainID)) { + const owner = await this._method.getNFTOwner(getMethodContext(), nftID); + if (!owner.equals(sendingChainID)) { + throw new Error('NFT has not been properly escrowed'); + } } if (!nftChainID.equals(ownChainID) && nftExists) { diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index 354f587b1b2..200df9e5523 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -280,7 +280,7 @@ describe('CrossChain Transfer Command', () => { eventQueue, getStore, logger: fakeLogger, - chainID, + chainID: newConfig.ownChainID, }; await expect(command.verify(context)).rejects.toThrow( @@ -291,9 +291,7 @@ describe('CrossChain Transfer Command', () => { it('should throw if nft chain id equals own chain id but no entry exists in nft substore for the nft id', async () => { await nftStore.del(methodContext, nftID); - await expect(command.verify(context)).rejects.toThrow( - 'Non-existent entry in the NFT substore', - ); + await expect(command.verify(context)).rejects.toThrow('NFT substore entry does not exist'); }); it('should throw if nft chain id equals own chain id but the owner of nft is different from the sending chain', async () => { @@ -306,6 +304,32 @@ describe('CrossChain Transfer Command', () => { await expect(command.verify(context)).rejects.toThrow('NFT has not been properly escrowed'); }); + it('should not throw if nft chain id is not equal to own chain id and no entry exists in nft substore for the nft id', async () => { + const newConfig = { + ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; + method.init(newConfig); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(newConfig); + context = { + ccm, + transaction: defaultTransaction, + header: defaultHeader, + stateStore, + contextStore, + getMethodContext, + eventQueue: new EventQueue(0), + getStore, + logger: fakeLogger, + chainID: newConfig.ownChainID, + }; + await nftStore.del(methodContext, nftID); + + await expect(command.verify(context)).resolves.toBeUndefined(); + }); + it('throw if nft chain id is not equal to own chain id and entry already exists in nft substore for the nft id', async () => { const newConfig = { ownChainID: utils.getRandomBytes(LENGTH_CHAIN_ID), From 1fd3fe1fc55536b4f260f4e78b82f9a81404b0d5 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 11 Aug 2023 08:19:15 +0100 Subject: [PATCH 57/58] Method isNFTSupported incorrectly checks if the nft already exists (#8835) * Update verify hook * Remove check --------- Co-authored-by: shuse2 --- .../src/modules/nft/cc_commands/cc_transfer.ts | 4 ++++ framework/src/modules/nft/method.ts | 6 ------ .../modules/nft/cc_comands/cc_transfer.spec.ts | 4 +++- framework/test/unit/modules/nft/method.spec.ts | 14 -------------- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/framework/src/modules/nft/cc_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts index f20c72359b6..c8f9000446a 100644 --- a/framework/src/modules/nft/cc_commands/cc_transfer.ts +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -72,6 +72,10 @@ export class CrossChainTransferCommand extends BaseCCCommand { const nftExists = await nftStore.has(getMethodContext(), nftID); if (nftChainID.equals(ownChainID)) { + if (!nftExists) { + throw new Error('Non-existent entry in the NFT substore'); + } + const owner = await this._method.getNFTOwner(getMethodContext(), nftID); if (!owner.equals(sendingChainID)) { throw new Error('NFT has not been properly escrowed'); diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 7571db37d66..b1be7aa54b6 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -200,12 +200,6 @@ export class NFTMethod extends BaseMethod { methodContext: ImmutableMethodContext, nftID: Buffer, ): Promise { - const nftStore = this.stores.get(NFTStore); - const nftExists = await nftStore.has(methodContext, nftID); - if (!nftExists) { - throw new Error('NFT substore entry does not exist'); - } - const nftChainID = this.getChainID(nftID); if (nftChainID.equals(this._config.ownChainID)) { return true; diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index 200df9e5523..c2fadce28a4 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -291,7 +291,9 @@ describe('CrossChain Transfer Command', () => { it('should throw if nft chain id equals own chain id but no entry exists in nft substore for the nft id', async () => { await nftStore.del(methodContext, nftID); - await expect(command.verify(context)).rejects.toThrow('NFT substore entry does not exist'); + await expect(command.verify(context)).rejects.toThrow( + 'Non-existent entry in the NFT substore', + ); }); it('should throw if nft chain id equals own chain id but the owner of nft is different from the sending chain', async () => { diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 628ca3902ae..d1c933dfbb2 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -380,20 +380,6 @@ describe('NFTMethod', () => { }); describe('isNFTSupported', () => { - beforeEach(async () => { - await nftStore.save(methodContext, nftID, { - owner: utils.getRandomBytes(LENGTH_CHAIN_ID), - attributesArray: [], - }); - }); - - it('should throw if entry does not exist in the nft substore for the nft id', async () => { - await nftStore.del(methodContext, nftID); - await expect(method.isNFTSupported(methodContext, nftID)).rejects.toThrow( - 'NFT substore entry does not exist', - ); - }); - it('should return true if nft chain id equals own chain id', async () => { const isSupported = await method.isNFTSupported(methodContext, existingNativeNFT.nftID); expect(isSupported).toBe(true); From 78ee063d614a282cb2625d52cf6eec487efbf49f Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Fri, 11 Aug 2023 08:20:22 +0100 Subject: [PATCH 58/58] Update example app (#8836) * Update verify hook * Remove check * Update example app --- .../config/default/genesis_assets.json | 89 ++++++++++++++++++ .../config/default/genesis_block.blob | Bin 5920 -> 5933 bytes .../config/default/genesis_assets.json | 89 ++++++++++++++++++ .../config/default/genesis_block.blob | Bin 5924 -> 5941 bytes .../pos-sidechain-example-one/src/app/app.ts | 2 +- 5 files changed, 179 insertions(+), 1 deletion(-) diff --git a/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json b/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json index e091a26a9ad..f4fd8965034 100644 --- a/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json +++ b/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json @@ -1043,6 +1043,95 @@ } } }, + { + "module": "nft", + "data": { + "nftSubstore": [], + "supportedNFTsSubstore": [ + { + "chainID": "", + "supportedCollectionIDArray": [] + } + ] + }, + "schema": { + "$id": "/nft/module/genesis", + "type": "object", + "required": ["nftSubstore", "supportedNFTsSubstore"], + "properties": { + "nftSubstore": { + "type": "array", + "fieldNumber": 1, + "items": { + "type": "object", + "required": ["nftID", "owner", "attributesArray"], + "properties": { + "nftID": { + "dataType": "bytes", + "fieldNumber": 1, + "minLength": 16, + "maxLength": 16 + }, + "owner": { + "dataType": "bytes", + "fieldNumber": 2 + }, + "attributesArray": { + "type": "array", + "fieldNumber": 3, + "items": { + "type": "object", + "required": ["module", "attributes"], + "properties": { + "module": { + "dataType": "string", + "minLength": 1, + "maxLength": 32, + "fieldNumber": 1 + }, + "attributes": { + "dataType": "bytes", + "fieldNumber": 2 + } + } + } + } + } + } + }, + "supportedNFTsSubstore": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["chainID", "supportedCollectionIDArray"], + "properties": { + "chainID": { + "dataType": "bytes", + "fieldNumber": 1 + }, + "supportedCollectionIDArray": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["collectionID"], + "properties": { + "collectionID": { + "dataType": "bytes", + "minLength": 4, + "maxLength": 4, + "fieldNumber": 1 + } + } + } + } + } + } + } + } + } + }, { "module": "pos", "data": { diff --git a/examples/interop/pos-mainchain-fast/config/default/genesis_block.blob b/examples/interop/pos-mainchain-fast/config/default/genesis_block.blob index e44d19a3fd2c2a7dae2f4d7b813e790a927c0da7..df3834bf72f497aa81b194ea17f447b44e3f942c 100644 GIT binary patch delta 115 zcmV-(0F3{jF0C#P3j6^G01%nv&!z?#03slf5gw5(JRsdiEO~xRJMlh30)L$jGgkxl zya1l%ZW6ofF2cYVy>5|-Bp|W(X!lQBGmhYiK)9Vy*%Oz87iJ3^luC?qbI^NG{tc1M VTMG*c18!z?5(E+g3bQ5w`V|03EJ*+W delta 103 zcmV-t0GR)+E}$+C3j6^G01(#il%fV003slf5gw5(JRtL+-{@FOJ%6bLMGv#gE*CDX z8w^ez654C)H_6X8e5jF$Bp|zls{t07?%f^jUfx!e6`9$?kd>CM7+yl{L`~S2yFHQ3 JTeCF*_!T(tELQ*k diff --git a/examples/interop/pos-sidechain-example-one/config/default/genesis_assets.json b/examples/interop/pos-sidechain-example-one/config/default/genesis_assets.json index 57352439b5d..34048e76cc8 100644 --- a/examples/interop/pos-sidechain-example-one/config/default/genesis_assets.json +++ b/examples/interop/pos-sidechain-example-one/config/default/genesis_assets.json @@ -1048,6 +1048,95 @@ } } }, + { + "module": "nft", + "data": { + "nftSubstore": [], + "supportedNFTsSubstore": [ + { + "chainID": "04000000", + "supportedCollectionIDArray": [] + } + ] + }, + "schema": { + "$id": "/nft/module/genesis", + "type": "object", + "required": ["nftSubstore", "supportedNFTsSubstore"], + "properties": { + "nftSubstore": { + "type": "array", + "fieldNumber": 1, + "items": { + "type": "object", + "required": ["nftID", "owner", "attributesArray"], + "properties": { + "nftID": { + "dataType": "bytes", + "fieldNumber": 1, + "minLength": 16, + "maxLength": 16 + }, + "owner": { + "dataType": "bytes", + "fieldNumber": 2 + }, + "attributesArray": { + "type": "array", + "fieldNumber": 3, + "items": { + "type": "object", + "required": ["module", "attributes"], + "properties": { + "module": { + "dataType": "string", + "minLength": 1, + "maxLength": 32, + "fieldNumber": 1 + }, + "attributes": { + "dataType": "bytes", + "fieldNumber": 2 + } + } + } + } + } + } + }, + "supportedNFTsSubstore": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["chainID", "supportedCollectionIDArray"], + "properties": { + "chainID": { + "dataType": "bytes", + "fieldNumber": 1 + }, + "supportedCollectionIDArray": { + "type": "array", + "fieldNumber": 2, + "items": { + "type": "object", + "required": ["collectionID"], + "properties": { + "collectionID": { + "dataType": "bytes", + "minLength": 4, + "maxLength": 4, + "fieldNumber": 1 + } + } + } + } + } + } + } + } + } + }, { "module": "pos", "data": { diff --git a/examples/interop/pos-sidechain-example-one/config/default/genesis_block.blob b/examples/interop/pos-sidechain-example-one/config/default/genesis_block.blob index 17af1bc9ec41bd4167f8857a7d03ad47976709f7..3e59180b2d3277e3ed8abdff6367ac006b4b6b9a 100644 GIT binary patch delta 119 zcmV--0EqvjF10QX3j6^G01&I>&!z?#03slf5gw5(JRo?>eKotWi{M;&${E={{P!bJ zgU(y%*Olj9fE#TZMV66?Bp}6L*J1aD3jg6d8o;LVHdshcz9W>T(v8!U{5O=oGqjP- ZO%D$W18!z?5(p9o3IqfI0J9hY3>FKyF!lfd delta 103 zcmV-t0GR)^E~G9G3j6^G01%Y$l%fV003slf5gw5(JRp7uS0a)-`cV81xf$>i)qkH_ z#!O9ErXQVgQO}H+Rp^n4Bp`06SApz^H4k01SvkdH$;RU3Zp*2xtyo56FpD4a!(5Tg JO|vTj3Kl`rEF1s; diff --git a/examples/interop/pos-sidechain-example-one/src/app/app.ts b/examples/interop/pos-sidechain-example-one/src/app/app.ts index 3250d66e460..61d1e557edf 100644 --- a/examples/interop/pos-sidechain-example-one/src/app/app.ts +++ b/examples/interop/pos-sidechain-example-one/src/app/app.ts @@ -4,7 +4,7 @@ import { registerModules } from './modules'; import { registerPlugins } from './plugins'; export const getApplication = (config: PartialApplicationConfig): Application => { - const { app, method } = Application.defaultApplication(config, true); + const { app, method } = Application.defaultApplication(config, false); const nftModule = new NFTModule(); const testNftModule = new TestNftModule();