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/config/default/genesis_assets.json b/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json index 4b9c333214f..d1afa37da2c 100644 --- a/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json +++ b/examples/interop/pos-mainchain-fast/config/default/genesis_assets.json @@ -1048,6 +1048,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 0f836f0e568..ded37d914ed 100644 Binary files a/examples/interop/pos-mainchain-fast/config/default/genesis_block.blob and b/examples/interop/pos-mainchain-fast/config/default/genesis_block.blob differ 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/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 cf989d7d9d0..0f733a67f99 100644 Binary files a/examples/interop/pos-sidechain-example-one/config/default/genesis_block.blob and b/examples/interop/pos-sidechain-example-one/config/default/genesis_block.blob differ 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..61d1e557edf 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, false); + + 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/.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/config/default/genesis_assets.json b/examples/pos-mainchain/config/default/genesis_assets.json index d575139c811..b92a196593d 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": 32, + "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", + "minLength": 4, + "maxLength": 4, + "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/pos-mainchain/package.json b/examples/pos-mainchain/package.json index 309041a11c0..9f0629fc026 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-beta.1", "@liskhq/lisk-framework-faucet-plugin": "^0.2.0-beta.1", "@liskhq/lisk-framework-forger-plugin": "^0.3.0-beta.1", diff --git a/examples/pos-mainchain/src/app/app.ts b/examples/pos-mainchain/src/app/app.ts index d4c1f2407cb..6a99bf61049 100644 --- a/examples/pos-mainchain/src/app/app.ts +++ b/examples/pos-mainchain/src/app/app.ts @@ -1,11 +1,19 @@ -import { Application, PartialApplicationConfig } 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); - registerModules(app); - registerPlugins(app); + 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); 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..822ad1b174f --- /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 '../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/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..bc5638846d4 --- /dev/null +++ b/examples/pos-mainchain/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/pos-mainchain/src/app/modules/testNft/constants.ts b/examples/pos-mainchain/src/app/modules/testNft/constants.ts new file mode 100644 index 00000000000..a0150fad36f --- /dev/null +++ b/examples/pos-mainchain/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/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..a228abff3af --- /dev/null +++ b/examples/pos-mainchain/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/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 new file mode 100644 index 00000000000..8d1af2d969a --- /dev/null +++ b/examples/pos-mainchain/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/framework/src/index.ts b/framework/src/index.ts index a8e71dfee0c..a312045fabb 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, 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_commands/cc_transfer.ts b/framework/src/modules/nft/cc_commands/cc_transfer.ts new file mode 100644 index 00000000000..c8f9000446a --- /dev/null +++ b/framework/src/modules/nft/cc_commands/cc_transfer.ts @@ -0,0 +1,163 @@ +/* + * 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 = 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'); + } + + const nftStore = this.stores.get(NFTStore); + 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'); + } + } + + 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 = context.chainID; + 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; + 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)); + } 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/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/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/commands/transfer_cross_chain.ts b/framework/src/modules/nft/commands/transfer_cross_chain.ts new file mode 100644 index 00000000000..533dd83948e --- /dev/null +++ b/framework/src/modules/nft/commands/transfer_cross_chain.ts @@ -0,0 +1,140 @@ +/* + * 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 { InteroperabilityMethod, TokenMethod } 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 (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'); + } + + 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, + context.header.timestamp, + ); + } +} diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts new file mode 100644 index 00000000000..475de2ac82b --- /dev/null +++ b/framework/src/modules/nft/constants.ts @@ -0,0 +1,50 @@ +/* + * 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_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 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 LENGTH_TOKEN_ID = 8; +export const MAX_LENGTH_DATA = 64; + +export const enum NftEventResult { + 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, + INVALID_RECEIVING_CHAIN = 14, + RESULT_INVALID_ACCOUNT = 15, +} + +export type NftErrorEventResult = Exclude; diff --git a/framework/src/modules/nft/endpoint.ts b/framework/src/modules/nft/endpoint.ts new file mode 100644 index 00000000000..b41b4afd07d --- /dev/null +++ b/framework/src/modules/nft/endpoint.ts @@ -0,0 +1,246 @@ +/* + * 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 * 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_ADDRESS, 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 { + private _nftMethod!: NFTMethod; + + 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 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, + attributesArray, + }; + } + + 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/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..990f267885b --- /dev/null +++ b/framework/src/modules/nft/events/ccm_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, 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, + ]); + } + + public error(ctx: EventQueuer, data: CCMTransferEventData, result: NftEventResult): void { + this.add(ctx, { ...data, result }, [data.senderAddress, data.recipientAddress], true); + } +} diff --git a/framework/src/modules/nft/events/create.ts b/framework/src/modules/nft/events/create.ts new file mode 100644 index 00000000000..be3f55ae96c --- /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, + maxLength: LENGTH_NFT_ID, + fieldNumber: 2, + }, + collectionID: { + dataType: 'bytes', + minLength: LENGTH_COLLECTION_ID, + maxLength: 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..15bf0ffb7ad --- /dev/null +++ b/framework/src/modules/nft/events/destroy.ts @@ -0,0 +1,59 @@ +/* + * 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 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, + maxLength: 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, + ]); + } + + 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/events/lock.ts b/framework/src/modules/nft/events/lock.ts new file mode 100644 index 00000000000..b52ba2de613 --- /dev/null +++ b/framework/src/modules/nft/events/lock.ts @@ -0,0 +1,66 @@ +/* + * 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, + NftErrorEventResult, + 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, + ]); + } + + public error(ctx: EventQueuer, data: LockEventData, result: NftErrorEventResult) { + this.add(ctx, { ...data, result }, [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..3997f19e7ea --- /dev/null +++ b/framework/src/modules/nft/events/recover.ts @@ -0,0 +1,57 @@ +/* + * 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, NftErrorEventResult } 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]); + } + + public error(ctx: EventQueuer, data: RecoverEventData, result: NftErrorEventResult): void { + this.add(ctx, { ...data, result }, [data.nftID], true); + } +} 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..a83d93d5ad5 --- /dev/null +++ b/framework/src/modules/nft/events/set_attributes.ts @@ -0,0 +1,57 @@ +/* + * 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 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]); + } + + public error(ctx: EventQueuer, data: SetAttributesEventData, result: NftErrorEventResult): void { + this.add(ctx, { ...data, result }, [data.nftID], true); + } +} 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..67423f07329 --- /dev/null +++ b/framework/src/modules/nft/events/transfer_cross_chain.ts @@ -0,0 +1,89 @@ +/* + * 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, NftErrorEventResult } 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, + ]); + } + + public error( + ctx: EventQueuer, + data: TransferCrossChainEventData, + result: NftErrorEventResult, + ): void { + this.add( + ctx, + { ...data, result }, + [data.senderAddress, data.recipientAddress, data.receivingChainID], + true, + ); + } +} 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/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..bb27b9eac6a --- /dev/null +++ b/framework/src/modules/nft/internal_method.ts @@ -0,0 +1,192 @@ +/* + * 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 { BaseMethod } from '../base_method'; +import { NFTStore, NFTAttributes } from './stores/nft'; +import { InteroperabilityMethod, ModuleConfig, NFTMethod } from './types'; +import { MethodContext } from '../../state_machine'; +import { TransferEvent } from './events/transfer'; +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 { crossChainNFTTransferMessageParamsSchema } from './schemas'; + +export class InternalMethod extends BaseMethod { + private _config!: ModuleConfig; + private _method!: NFTMethod; + 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 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, + 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 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, + 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, + }); + } + + public async transferCrossChainInternal( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + receivingChainID: Buffer, + messageFee: bigint, + data: string, + includeAttributes: boolean, + timestamp?: number, + ): 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, + codec.encode(crossChainNFTTransferMessageParamsSchema, { + nftID, + senderAddress, + recipientAddress, + attributesArray, + data, + }), + timestamp, + ); + } + + 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/method.ts b/framework/src/modules/nft/method.ts new file mode 100644 index 00000000000..b1be7aa54b6 --- /dev/null +++ b/framework/src/modules/nft/method.ts @@ -0,0 +1,1021 @@ +/* + * 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 { BaseMethod } from '../base_method'; +import { FeeMethod, InteroperabilityMethod, ModuleConfig, TokenMethod } from './types'; +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, + MAX_LENGTH_DATA, + NFT_NOT_LOCKED, + NftEventResult, +} from './constants'; +import { UserStore } from './stores/user'; +import { DestroyEvent } from './events/destroy'; +import { SupportedNFTsStore } from './stores/supported_nfts'; +import { CreateEvent } from './events/create'; +import { LockEvent } from './events/lock'; +import { TransferEvent } from './events/transfer'; +import { InternalMethod } from './internal_method'; +import { TransferCrossChainEvent } from './events/transfer_cross_chain'; +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'; +import { RecoverEvent } from './events/recover'; +import { EscrowStore } from './stores/escrow'; +import { SetAttributesEvent } from './events/set_attributes'; + +export class NFTMethod extends BaseMethod { + private _config!: ModuleConfig; + private _interoperabilityMethod!: InteroperabilityMethod; + private _internalMethod!: InternalMethod; + private _feeMethod!: FeeMethod; + private _tokenMethod!: TokenMethod; + + public init(config: ModuleConfig): void { + this._config = config; + } + + public addDependencies( + interoperabilityMethod: InteroperabilityMethod, + internalMethod: InternalMethod, + feeMethod: FeeMethod, + tokenMethod: TokenMethod, + ) { + this._interoperabilityMethod = interoperabilityMethod; + this._internalMethod = internalMethod; + this._feeMethod = feeMethod; + this._tokenMethod = tokenMethod; + } + + 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); + + 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; + } + + 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).error( + 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).error( + methodContext, + { + address, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(address)) { + this.events.get(DestroyEvent).error( + 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).error( + 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, + }); + } + + public async getCollectionID( + 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'); + } + return nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); + } + + public async isNFTSupported( + methodContext: ImmutableMethodContext, + nftID: Buffer, + ): Promise { + 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; + } + + 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 indexLength = LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID; + const nftStore = this.stores.get(NFTStore); + + const nftStoreData = await nftStore.iterate(methodContext, { + gte: Buffer.concat([this._config.ownChainID, collectionID, Buffer.alloc(indexLength, 0)]), + lte: Buffer.concat([this._config.ownChainID, collectionID, Buffer.alloc(indexLength, 255)]), + }); + + 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 index + BigInt(1); + } + + public async create( + methodContext: MethodContext, + address: Buffer, + 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 indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); + indexBytes.writeBigInt64BE(index); + + const nftID = Buffer.concat([this._config.ownChainID, collectionID, indexBytes]); + 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, + }); + } + + public async lock(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 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, + }); + } + + public async transfer( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + ): Promise { + 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 { + 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, + { + 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'); + } + + const nftChainID = this.getChainID(nftID); + 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, + { + 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, + ); + } + + 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, + }); + } + + 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; + 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)); + + 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/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts new file mode 100644 index 00000000000..a772194a149 --- /dev/null +++ b/framework/src/modules/nft/module.ts @@ -0,0 +1,305 @@ +/* + * 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 { 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'; +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'; +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'; +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 { + collectionExistsRequestSchema, + collectionExistsResponseSchema, + getCollectionIDsRequestSchema, + getCollectionIDsResponseSchema, + getEscrowedNFTIDsRequestSchema, + getEscrowedNFTIDsResponseSchema, + getNFTRequestSchema, + getNFTResponseSchema, + getNFTsRequestSchema, + getNFTsResponseSchema, + hasNFTRequestSchema, + 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 { 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, + NFT_NOT_LOCKED, +} from './constants'; + +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; + private _feeMethod!: FeeMethod; + private _tokenMethod!: TokenMethod; + + public commands = [this._transferCommand, this._ccTransferCommand]; + + 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( + AllNFTsFromChainSupportRemovedEvent, + new AllNFTsFromChainSupportRemovedEvent(this.name), + ); + this.events.register( + AllNFTsFromCollectionSupportedEvent, + new AllNFTsFromCollectionSupportedEvent(this.name), + ); + this.events.register( + AllNFTsFromCollectionSupportRemovedEvent, + new AllNFTsFromCollectionSupportRemovedEvent(this.name), + ); + 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 { + return 'nft'; + } + + public addDependencies( + interoperabilityMethod: InteroperabilityMethod, + feeMethod: FeeMethod, + tokenMethod: TokenMethod, + ) { + this._interoperabilityMethod = interoperabilityMethod; + this._feeMethod = feeMethod; + this._tokenMethod = tokenMethod; + this.method.addDependencies( + interoperabilityMethod, + this._internalMethod, + feeMethod, + tokenMethod, + ); + this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); + this.crossChainMethod.addDependencies(interoperabilityMethod); + this.endpoint.addDependencies(this.method); + } + + public metadata(): ModuleMetadata { + return { + ...this.baseMetadata(), + 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: [], + }; + } + + // 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, + }); + this._transferCommand.init({ method: this.method, internalMethod: this._internalMethod }); + } + + 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`); + } + + 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`, + ); + } + } + + nftIDKeySet.add(nft.nftID); + } + + 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); + const escrowStore = this.stores.get(EscrowStore); + const userStore = this.stores.get(UserStore); + + for (const nft of genesisStore.nftSubstore) { + const { owner, nftID, attributesArray } = nft; + + await nftStore.save(context, nftID, { + owner, + attributesArray, + }); + + 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) { + 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 new file mode 100644 index 00000000000..c3bfbc15e69 --- /dev/null +++ b/framework/src/modules/nft/schemas.ts @@ -0,0 +1,468 @@ +/* + * 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_CHAIN_ID, + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + LENGTH_TOKEN_ID, + MAX_LENGTH_MODULE_NAME, + MIN_LENGTH_MODULE_NAME, + MAX_LENGTH_DATA, +} 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_LENGTH_DATA, + fieldNumber: 3, + }, + }, +}; + +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_LENGTH_DATA, + fieldNumber: 5, + }, + }, +}; + +export interface CCTransferMessageParams { + nftID: Buffer; + attributesArray: { module: string; attributes: Buffer }[]; + senderAddress: Buffer; + 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_LENGTH_DATA, + 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, + }, + }, +}; + +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', + }, + }, +}; + +export const genesisNFTStoreSchema = { + $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: LENGTH_NFT_ID, + maxLength: LENGTH_NFT_ID, + fieldNumber: 1, + }, + owner: { + dataType: 'bytes', + 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, + }, + }, + }, + }, + }, + }, + }, + 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: LENGTH_COLLECTION_ID, + maxLength: LENGTH_COLLECTION_ID, + fieldNumber: 1, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/framework/src/modules/nft/stores/escrow.ts b/framework/src/modules/nft/stores/escrow.ts new file mode 100644 index 00000000000..b5d224088bd --- /dev/null +++ b/framework/src/modules/nft/stores/escrow.ts @@ -0,0 +1,32 @@ +/* + * 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; + + public getKey(receivingChainID: Buffer, nftID: Buffer): Buffer { + return Buffer.concat([receivingChainID, nftID]); + } +} diff --git a/framework/src/modules/nft/stores/nft.ts b/framework/src/modules/nft/stores/nft.ts new file mode 100644 index 00000000000..ec931e7be7b --- /dev/null +++ b/framework/src/modules/nft/stores/nft.ts @@ -0,0 +1,75 @@ +/* + * 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 NFTAttributes { + module: string; + attributes: Buffer; +} + +export interface NFTStoreData { + owner: Buffer; + attributesArray: NFTAttributes[]; +} + +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..63668534d31 --- /dev/null +++ b/framework/src/modules/nft/stores/supported_nfts.ts @@ -0,0 +1,71 @@ +/* + * 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, ImmutableStoreGetter, StoreGetter } from '../../base_store'; +import { LENGTH_COLLECTION_ID, LENGTH_CHAIN_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 }); + } + + 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/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/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts new file mode 100644 index 00000000000..64ccefa1a54 --- /dev/null +++ b/framework/src/modules/nft/types.ts @@ -0,0 +1,81 @@ +/* + * 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 { ImmutableMethodContext, MethodContext } from '../../state_machine'; +import { CCMsg } from '../interoperability'; + +export interface ModuleConfig { + ownChainID: Buffer; +} + +export interface InteroperabilityMethod { + send( + methodContext: MethodContext, + feeAddress: Buffer, + module: string, + crossChainCommand: string, + receivingChainID: Buffer, + fee: bigint, + parameters: Buffer, + timestamp?: number, + ): Promise; + error(methodContext: MethodContext, ccm: CCMsg, code: number): Promise; + terminateChain(methodContext: MethodContext, chainID: Buffer): Promise; + getMessageFeeTokenID(methodContext: ImmutableMethodContext, chainID: Buffer): Promise; +} + +export interface FeeMethod { + payFee(methodContext: MethodContext, amount: bigint): void; +} + +export interface TokenMethod { + getAvailableBalance( + methodContext: ImmutableMethodContext, + address: Buffer, + tokenID: Buffer, + ): Promise; +} + +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; +} + +export interface GenesisNFTStore { + nftSubstore: { + nftID: Buffer; + owner: Buffer; + attributesArray: { + module: string; + attributes: Buffer; + }[]; + }[]; + supportedNFTsSubstore: { + chainID: Buffer; + supportedCollectionIDArray: { + collectionID: Buffer; + }[]; + }[]; +} 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/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts new file mode 100644 index 00000000000..c2fadce28a4 --- /dev/null +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -0,0 +1,659 @@ +/* + * 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 tokenMethod = { + getAvailableBalance: 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(), + getMessageFeeTokenID: 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, internalMethod, feeMethod, tokenMethod); + 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: ownChainID, + }; + }); + + 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: newConfig.ownChainID, + }; + + 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('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), + 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 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: ownChainID, + }; + + 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); + 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: [], + }); + 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), + ); + expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); + expect(nftStoreData.owner).toStrictEqual(recipientAddress); + expect(nftStoreData.attributesArray).toEqual(attributesArray); + expect(userAccountExists).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), + ); + expect(feeMethod.payFee).not.toHaveBeenCalled(); + expect(nftStoreData.owner).toStrictEqual(senderAddress); + expect(nftStoreData.attributesArray).toEqual(attributesArray); + expect(userAccountExistsForRecipient).toBe(false); + expect(userAccountExistsForSender).toBe(true); + checkEventResult(context.eventQueue, 1, CcmTransferEvent, 0, { + senderAddress, + recipientAddress: senderAddress, + nftID, + }); + }); + }); +}); 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/commands/transfer_cross_chain.spec.ts b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts new file mode 100644 index 00000000000..897a8ae0732 --- /dev/null +++ b/framework/test/unit/modules/nft/commands/transfer_cross_chain.spec.ts @@ -0,0 +1,475 @@ +/* + * 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 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), + }); + + 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/endpoint.spec.ts b/framework/test/unit/modules/nft/endpoint.spec.ts new file mode 100644 index 00000000000..64fbabfe0d3 --- /dev/null +++ b/framework/test/unit/modules/nft/endpoint.spec.ts @@ -0,0 +1,786 @@ +/* + * 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'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; + +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 escrowStore = module.stores.get(EscrowStore); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + + let stateStore: PrefixedStateReadWriter; + let methodContext: MethodContext; + + const owner = utils.getRandomBytes(LENGTH_ADDRESS); + const ownerAddress = address.getLisk32AddressFromAddress(owner); + const escrowChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + + 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), { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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); + }); + + 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', () => { + 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 }); + }); + }); +}); 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..3c7a8bb338e --- /dev/null +++ b/framework/test/unit/modules/nft/init_genesis_state_fixtures.ts @@ -0,0 +1,199 @@ +/* + * 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 nftID3 = 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), + }, + ], + }, + { + nftID: nftID3, + owner: escrowedChainID, + attributesArray: [], + }, + ], + 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 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 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/internal_method.spec.ts b/framework/test/unit/modules/nft/internal_method.spec.ts new file mode 100644 index 00000000000..b9a843e008e --- /dev/null +++ b/framework/test/unit/modules/nft/internal_method.spec.ts @@ -0,0 +1,538 @@ +/* + * 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 { + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + 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'; +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 { 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 = ( + 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 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); + let nftID = utils.getRandomBytes(LENGTH_NFT_ID); + + beforeEach(() => { + methodContext = createMethodContext({ + stateStore: new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()), + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + }); + + 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 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', + 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'); + }); + }); + + describe('transferCrossChainInternal', () => { + let receivingChainID: Buffer; + const messageFee = BigInt(1000); + const data = ''; + const timestamp = Math.floor(Date.now() / 1000); + + 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()), + getMessageFeeTokenID: jest + .fn() + .mockResolvedValue(Promise.resolve(utils.getRandomBytes(LENGTH_TOKEN_ID))), + }; + + 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, + timestamp, + ), + ).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, + ccmParameters, + timestamp, + ); + }); + + 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, + timestamp, + ), + ).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, + ccmParameters, + timestamp, + ); + }); + }); + + 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, + timestamp, + ), + ).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, + ccmParameters, + timestamp, + ); + }); + + 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, + timestamp, + ), + ).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, + ccmParameters, + timestamp, + ); + }); + }); + }); +}); 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..d1c933dfbb2 --- /dev/null +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -0,0 +1,1868 @@ +/* + * 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 { when } from 'jest-when'; +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 { + ALL_SUPPORTED_NFTS_KEY, + FEE_CREATE_NFT, + LENGTH_ADDRESS, + LENGTH_CHAIN_ID, + LENGTH_COLLECTION_ID, + LENGTH_NFT_ID, + LENGTH_TOKEN_ID, + NFT_NOT_LOCKED, + NftEventResult, +} from '../../../../src/modules/nft/constants'; +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'; +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'; +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'; +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(); + 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; + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); + + 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 = ( + 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, + ); + + 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 }; + + 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({ + stateStore: new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()), + eventQueue: new EventQueue(0), + contextStore: new Map(), + }); + + existingNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: utils.getRandomBytes(LENGTH_NFT_ID), + }; + + existingNativeNFT = { + owner: utils.getRandomBytes(LENGTH_ADDRESS), + nftID: Buffer.concat([config.ownChainID, Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_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 nftStore.save(methodContext, existingNativeNFT.nftID, { + owner: existingNativeNFT.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', () => { + 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( + '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); + }); + }); + + describe('destroy', () => { + 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, + }); + }); + }); + + 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', () => { + 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); + }); + + it('should return true if nft chain id does not equal own chain id but all nft keys are supported', async () => { + 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 () => { + 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 () => { + 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 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, foreignNFT); + 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 attributesArray = [ + { module: 'customMod1', attributes: Buffer.alloc(5) }, + { module: 'customMod2', attributes: Buffer.alloc(2) }, + ]; + 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, + }); + }); + + 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, 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 () => { + const returnedIndex = await method.getNextAvailableIndex( + methodContext, + utils.getRandomBytes(LENGTH_COLLECTION_ID), + ); + expect(returnedIndex).toBe(BigInt(0)); + }); + + 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(BigInt(420)); + }); + + 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, + }); + + await expect(method.getNextAvailableIndex(methodContext, collectionID)).rejects.toThrow( + 'No more available indexes', + ); + }); + }); + + describe('create', () => { + 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); + + beforeEach(() => { + method.addDependencies(interopMethod, internalMethod, feeMethod, tokenMethod); + 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 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, attributesArray2); + 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(attributesArray2); + 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 () => { + const indexBytes = Buffer.alloc(LENGTH_NFT_ID - LENGTH_CHAIN_ID - LENGTH_COLLECTION_ID); + indexBytes.writeBigUint64BE(BigInt(911)); + const newKey = Buffer.concat([config.ownChainID, collectionID, indexBytes]); + await nftStore.save(methodContext, newKey, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: attributesArray1, + }); + + 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, + userStore.getKey(address, expectedKey), + ); + expect(feeMethod.payFee).toHaveBeenCalledWith(methodContext, BigInt(FEE_CREATE_NFT)); + expect(nftStoreData.owner).toStrictEqual(address); + expect(nftStoreData.attributesArray).toEqual(attributesArray2); + expect(userStoreData.lockingModule).toEqual(NFT_NOT_LOCKED); + checkEventResult(methodContext.eventQueue, 1, CreateEvent, 0, { + address, + nftID: expectedKey, + collectionID, + }); + }); + }); + + 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); + }); + }); + + 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 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 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 () => { + const nonExistingNFTID = utils.getRandomBytes(LENGTH_NFT_ID); + receivingChainID = nonExistingNFTID.slice(0, LENGTH_CHAIN_ID); + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + nonExistingNFTID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID: nonExistingNFTID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + 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, + 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 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( + 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 () => { + receivingChainID = lockedExistingNFT.nftID.slice(0, LENGTH_CHAIN_ID); + 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, + ); + }); + }); + + 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, config.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, config.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, + config.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, + config.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, + ); + }); + }); + + describe('recover', () => { + const terminatedChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const substorePrefix = Buffer.from('0000', '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 SetAttributesEvent 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); + }); + }); +}); 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..99ae42a0dd3 --- /dev/null +++ b/framework/test/unit/modules/nft/module.spec.ts @@ -0,0 +1,481 @@ +/* + * 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 { BlockAssets } from '@liskhq/lisk-chain'; +import { NFTModule } from '../../../../src/modules/nft/module'; +import { createGenesisBlockContext } from '../../../../src/testing'; +import { + invalidSchemaNFTSubstoreGenesisAssets, + invalidSchemaSupportedNFTsSubstoreGenesisAssets, + 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, + 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'; +import { UserStore } from '../../../../src/modules/nft/stores/user'; +import { EscrowStore } from '../../../../src/modules/nft/stores/escrow'; + +describe('nft module', () => { + const module = new NFTModule(); + + const nftStore = module.stores.get(NFTStore); + const userStore = module.stores.get(UserStore); + const escrowStore = module.stores.get(EscrowStore); + const supportedNFTsSubstore = module.stores.get(SupportedNFTsStore); + + const createGenesisBlockContextFromGenesisAssets = (genesisAssets: object) => { + const encodedAsset = codec.encode(genesisNFTStoreSchema, genesisAssets); + + const context = createGenesisBlockContext({ + assets: new BlockAssets([{ module: module.name, data: encodedAsset }]), + }).createInitGenesisStateContext(); + + 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) => { + 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 has duplicate attribute for a module', 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), + }, + ], + }, + ], + }; + + 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 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); + + const genesisAssets = { + ...validData, + nftSubstore: [ + { + nftID: nftID1, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + { + nftID: nftID2, + owner: utils.getRandomBytes(LENGTH_ADDRESS), + attributesArray: [], + }, + ], + }; + + 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), + }, + ], + }, + ], + }; + + 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), + }, + ], + }, + ], + }; + + 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 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, + }, + ]); + }); + }); +}); 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]), + ); + }); + }); +}); 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..054ad566eaa --- /dev/null +++ b/framework/test/unit/modules/nft/stores/supported_nfts.spec.ts @@ -0,0 +1,86 @@ +/* + * 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 { 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_CHAIN_ID, 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, + }); + }); + }); + + 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/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])); + }); + }); +}); 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(