From a2005c8db8888c6bb66321cc2997034f2b2b6737 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Tue, 13 Jun 2023 22:59:21 +0200 Subject: [PATCH 1/2] Implement methods --- framework/src/modules/nft/constants.ts | 3 + framework/src/modules/nft/events/recover.ts | 6 +- .../src/modules/nft/events/set_attributes.ts | 6 +- .../nft/events/transfer_cross_chain.ts | 15 +- framework/src/modules/nft/internal_method.ts | 3 +- framework/src/modules/nft/method.ts | 211 +++++++++- framework/src/modules/nft/module.ts | 25 +- framework/src/modules/nft/schemas.ts | 8 +- framework/src/modules/nft/types.ts | 13 + .../nft/cc_comands/cc_transfer.spec.ts | 5 +- .../test/unit/modules/nft/method.spec.ts | 374 +++++++++++++++--- 11 files changed, 596 insertions(+), 73 deletions(-) diff --git a/framework/src/modules/nft/constants.ts b/framework/src/modules/nft/constants.ts index e14f1ded273..475de2ac82b 100644 --- a/framework/src/modules/nft/constants.ts +++ b/framework/src/modules/nft/constants.ts @@ -26,6 +26,7 @@ export const EMPTY_BYTES = Buffer.alloc(0); export const ALL_SUPPORTED_NFTS_KEY = EMPTY_BYTES; export const FEE_CREATE_NFT = 5000000; export const LENGTH_TOKEN_ID = 8; +export const MAX_LENGTH_DATA = 64; export const enum NftEventResult { RESULT_SUCCESSFUL = 0, @@ -42,6 +43,8 @@ export const enum NftEventResult { RESULT_RECOVER_FAIL_INVALID_INPUTS = 11, RESULT_INSUFFICIENT_BALANCE = 12, RESULT_DATA_TOO_LONG = 13, + INVALID_RECEIVING_CHAIN = 14, + RESULT_INVALID_ACCOUNT = 15, } export type NftErrorEventResult = Exclude; diff --git a/framework/src/modules/nft/events/recover.ts b/framework/src/modules/nft/events/recover.ts index 589e0585f12..3997f19e7ea 100644 --- a/framework/src/modules/nft/events/recover.ts +++ b/framework/src/modules/nft/events/recover.ts @@ -13,7 +13,7 @@ */ import { BaseEvent, EventQueuer } from '../../base_event'; -import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult } from '../constants'; +import { LENGTH_NFT_ID, LENGTH_CHAIN_ID, NftEventResult, NftErrorEventResult } from '../constants'; export interface RecoverEventData { terminatedChainID: Buffer; @@ -50,4 +50,8 @@ export class RecoverEvent extends BaseEvent { + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(senderAddress)) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + throw new Error('Transfer not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userData = await userStore.get(methodContext, userStore.getKey(owner, nftID)); + if (userData.lockingModule !== NFT_NOT_LOCKED) { + this.events.get(TransferEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + throw new Error('Locked NFTs cannot be transferred'); + } + + await this._internalMethod.transferInternal(methodContext, recipientAddress, nftID); + } + + public async transferCrossChain( + methodContext: MethodContext, + senderAddress: Buffer, + recipientAddress: Buffer, + nftID: Buffer, + receivingChainID: Buffer, + messageFee: bigint, + data: string, + includeAttributes: boolean, + ): Promise { + if (data.length > MAX_LENGTH_DATA) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_DATA_TOO_LONG, + ); + throw new Error('Data field is too long'); + } + + const nftStore = this.stores.get(NFTStore); + const nftExists = await nftStore.has(methodContext, nftID); + if (!nftExists) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + throw new Error('NFT substore entry does not exist'); + } + + const owner = await this.getNFTOwner(methodContext, nftID); + if (owner.length === LENGTH_CHAIN_ID) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + throw new Error('NFT is escrowed to another chain'); + } + + if (!owner.equals(senderAddress)) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + throw new Error('Transfer not initiated by the NFT owner'); + } + + const userStore = this.stores.get(UserStore); + const userData = await userStore.get(methodContext, userStore.getKey(owner, nftID)); + if (userData.lockingModule !== NFT_NOT_LOCKED) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + throw new Error('Locked NFTs cannot be transferred'); + } + + const messageFeeTokenID = await this._interoperabilityMethod.getMessageFeeTokenID( + methodContext, + receivingChainID, + ); + const availableBalance = await this._tokenMethod.getAvailableBalance( + methodContext, + senderAddress, + messageFeeTokenID, + ); + if (availableBalance < messageFee) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_INSUFFICIENT_BALANCE, + ); + throw new Error('Insufficient balance for the message fee'); + } + + await this._internalMethod.transferCrossChainInternal( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ); + } } diff --git a/framework/src/modules/nft/module.ts b/framework/src/modules/nft/module.ts index 5518b54092d..abf30662b51 100644 --- a/framework/src/modules/nft/module.ts +++ b/framework/src/modules/nft/module.ts @@ -38,18 +38,24 @@ import { EscrowStore } from './stores/escrow'; import { NFTStore } from './stores/nft'; import { SupportedNFTsStore } from './stores/supported_nfts'; import { UserStore } from './stores/user'; -import { FeeMethod } from './types'; +import { FeeMethod, TokenMethod } from './types'; +import { CrossChainTransferCommand as CrossChainTransferMessageCommand } from './cc_commands/cc_transfer'; +import { TransferCrossChainCommand } from './commands/transfer_cross_chain'; +import { TransferCommand } from './commands/transfer'; export class NFTModule extends BaseInteroperableModule { public method = new NFTMethod(this.stores, this.events); public endpoint = new NFTEndpoint(this.stores, this.offchainStores); public crossChainMethod = new NFTInteroperableMethod(this.stores, this.events); + public crossChainTransferCommand = new CrossChainTransferMessageCommand(this.stores, this.events); + public crossChainCommand = [this.crossChainTransferCommand]; + private readonly _transferCommand = new TransferCommand(this.stores, this.events); + private readonly _ccTransferCommand = new TransferCrossChainCommand(this.stores, this.events); private readonly _internalMethod = new InternalMethod(this.stores, this.events); - private _interoperabilityMethod!: InteroperabilityMethod; - public commands = []; + public commands = [this._transferCommand, this._ccTransferCommand]; // eslint-disable-next-line no-useless-constructor public constructor() { @@ -84,9 +90,18 @@ export class NFTModule extends BaseInteroperableModule { this.stores.register(SupportedNFTsStore, new SupportedNFTsStore(this.name, 4)); } - public addDependencies(interoperabilityMethod: InteroperabilityMethod, feeMethod: FeeMethod) { + public addDependencies( + interoperabilityMethod: InteroperabilityMethod, + feeMethod: FeeMethod, + tokenMethod: TokenMethod, + ) { this._interoperabilityMethod = interoperabilityMethod; - this.method.addDependencies(interoperabilityMethod, feeMethod); + this.method.addDependencies( + interoperabilityMethod, + this._internalMethod, + feeMethod, + tokenMethod, + ); this._internalMethod.addDependencies(this.method, this._interoperabilityMethod); this.crossChainMethod.addDependencies(interoperabilityMethod); } diff --git a/framework/src/modules/nft/schemas.ts b/framework/src/modules/nft/schemas.ts index 9b261363e18..506b5216d11 100644 --- a/framework/src/modules/nft/schemas.ts +++ b/framework/src/modules/nft/schemas.ts @@ -12,13 +12,13 @@ * Removal or modification of this copyright notice is prohibited. */ -import { MAX_DATA_LENGTH } from '../token/constants'; import { LENGTH_CHAIN_ID, LENGTH_NFT_ID, LENGTH_TOKEN_ID, MAX_LENGTH_MODULE_NAME, MIN_LENGTH_MODULE_NAME, + MAX_LENGTH_DATA, } from './constants'; export const transferParamsSchema = { @@ -40,7 +40,7 @@ export const transferParamsSchema = { data: { dataType: 'string', minLength: 0, - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 3, }, }, @@ -90,7 +90,7 @@ export const crossChainNFTTransferMessageParamsSchema = { }, data: { dataType: 'string', - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 5, }, }, @@ -137,7 +137,7 @@ export const crossChainTransferParamsSchema = { data: { dataType: 'string', minLength: 0, - maxLength: MAX_DATA_LENGTH, + maxLength: MAX_LENGTH_DATA, fieldNumber: 4, }, messageFee: { diff --git a/framework/src/modules/nft/types.ts b/framework/src/modules/nft/types.ts index d71c76e83a7..c6b63a9d44f 100644 --- a/framework/src/modules/nft/types.ts +++ b/framework/src/modules/nft/types.ts @@ -40,3 +40,16 @@ export interface InteroperabilityMethod { export interface FeeMethod { payFee(methodContext: MethodContext, amount: bigint): void; } + +export interface TokenMethod { + getAvailableBalance( + methodContext: MethodContext, + address: Buffer, + tokenID: Buffer, + ): Promise; +} + +export interface NFTMethod { + getChainID(nftID: Buffer): Buffer; + destroy(methodContext: MethodContext, address: Buffer, nftID: Buffer): Promise; +} diff --git a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts index c84cbee033d..62f7e01c207 100644 --- a/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts +++ b/framework/test/unit/modules/nft/cc_comands/cc_transfer.spec.ts @@ -47,6 +47,9 @@ describe('CrossChain Transfer Command', () => { const method = new NFTMethod(module.stores, module.events); const internalMethod = new InternalMethod(module.stores, module.events); const feeMethod = { payFee: jest.fn() }; + const tokenMethod = { + getAvailableBalance: jest.fn(), + }; const checkEventResult = ( eventQueue: EventQueue, length: number, @@ -128,7 +131,7 @@ describe('CrossChain Transfer Command', () => { beforeEach(async () => { stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); - method.addDependencies(interopMethod, feeMethod); + method.addDependencies(interopMethod, internalMethod, feeMethod, tokenMethod); method.init(config); internalMethod.addDependencies(method, interopMethod); internalMethod.init(config); diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index 1f14775963f..d4b5a78438e 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -14,6 +14,7 @@ import { codec } from '@liskhq/lisk-codec'; import { utils } from '@liskhq/lisk-cryptography'; +import { when } from 'jest-when'; import { NFTMethod } from '../../../../src/modules/nft/method'; import { NFTModule } from '../../../../src/modules/nft/module'; import { EventQueue } from '../../../../src/state_machine'; @@ -27,6 +28,7 @@ import { LENGTH_CHAIN_ID, LENGTH_COLLECTION_ID, LENGTH_NFT_ID, + LENGTH_TOKEN_ID, NFT_NOT_LOCKED, NftEventResult, } from '../../../../src/modules/nft/constants'; @@ -36,10 +38,33 @@ import { DestroyEvent, DestroyEventData } from '../../../../src/modules/nft/even import { SupportedNFTsStore } from '../../../../src/modules/nft/stores/supported_nfts'; import { CreateEvent } from '../../../../src/modules/nft/events/create'; import { LockEvent, LockEventData } from '../../../../src/modules/nft/events/lock'; +import { InternalMethod } from '../../../../src/modules/nft/internal_method'; +import { TransferEvent, TransferEventData } from '../../../../src/modules/nft/events/transfer'; +import { + TransferCrossChainEvent, + TransferCrossChainEventData, +} from '../../../../src/modules/nft/events/transfer_cross_chain'; describe('NFTMethod', () => { const module = new NFTModule(); const method = new NFTMethod(module.stores, module.events); + const internalMethod = new InternalMethod(module.stores, module.events); + const messageFeeTokenID = utils.getRandomBytes(LENGTH_TOKEN_ID); + const interopMethod = { + send: jest.fn(), + error: jest.fn(), + terminateChain: jest.fn(), + getMessageFeeTokenID: jest.fn().mockResolvedValue(Promise.resolve(messageFeeTokenID)), + }; + const feeMethod = { payFee: jest.fn() }; + const tokenMethod = { + getAvailableBalance: jest.fn(), + }; + const config = { + ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), + escrowAccountInitializationFee: BigInt(50000000), + userAccountInitializationFee: BigInt(50000000), + }; let methodContext!: MethodContext; @@ -73,6 +98,10 @@ describe('NFTMethod', () => { let escrowedNFT: { nftID: any; owner: any }; beforeEach(async () => { + method.addDependencies(interopMethod, internalMethod, feeMethod, tokenMethod); + method.init(config); + internalMethod.addDependencies(method, interopMethod); + internalMethod.init(config); owner = utils.getRandomBytes(LENGTH_ADDRESS); methodContext = createMethodContext({ @@ -307,6 +336,7 @@ describe('NFTMethod', () => { }); describe('isNFTSupported', () => { + const supportedNFTsStore = module.stores.get(SupportedNFTsStore); beforeEach(async () => { await nftStore.save(methodContext, nftID, { owner: utils.getRandomBytes(LENGTH_CHAIN_ID), @@ -322,27 +352,16 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id equals own chain id', async () => { - const ownChainID = nftID.slice(0, LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - - const isSupported = await method.isNFTSupported(methodContext, nftID); + const newNftID = Buffer.alloc(LENGTH_NFT_ID, 1); + await nftStore.save(methodContext, newNftID, { + owner: utils.getRandomBytes(LENGTH_CHAIN_ID), + attributesArray: [], + }); + const isSupported = await method.isNFTSupported(methodContext, newNftID); expect(isSupported).toBe(true); }); it('should return true if nft chain id does not equal own chain id but all nft keys are supported', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, ALL_SUPPORTED_NFTS_KEY, { supportedCollectionIDArray: [], }); @@ -352,14 +371,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array is empty', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [], }); @@ -369,14 +380,6 @@ describe('NFTMethod', () => { }); it('should return true if nft chain id does not equal own chain id but nft chain id is supported and corresponding supported collection id array includes collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID) }, @@ -389,14 +392,6 @@ describe('NFTMethod', () => { }); it('should return false if nft chain id does not equal own chain id and nft chain id is supported but corresponding supported collection id array does not include collection id for nft id', async () => { - const ownChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); - const config = { - ownChainID, - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; - method.init(config); - const supportedNFTsStore = module.stores.get(SupportedNFTsStore); await supportedNFTsStore.set(methodContext, nftID.slice(0, LENGTH_CHAIN_ID), { supportedCollectionIDArray: [ { collectionID: utils.getRandomBytes(LENGTH_COLLECTION_ID) }, @@ -515,30 +510,16 @@ describe('NFTMethod', () => { }); describe('create', () => { - const interopMethod = { - send: jest.fn(), - error: jest.fn(), - terminateChain: jest.fn(), - getMessageFeeTokenID: jest.fn(), - }; - const feeMethod = { payFee: jest.fn() }; const attributesArray1 = [ { module: 'customMod1', attributes: Buffer.alloc(5) }, { module: 'customMod2', attributes: Buffer.alloc(2) }, ]; const attributesArray2 = [{ module: 'customMod3', attributes: Buffer.alloc(7) }]; const attributesArray3 = [{ module: 'customMod3', attributes: Buffer.alloc(9) }]; - const config = { - ownChainID: Buffer.alloc(LENGTH_CHAIN_ID, 1), - escrowAccountInitializationFee: BigInt(50000000), - userAccountInitializationFee: BigInt(50000000), - }; const collectionID = nftID.slice(LENGTH_CHAIN_ID, LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID); const address = utils.getRandomBytes(LENGTH_ADDRESS); beforeEach(() => { - method.addDependencies(interopMethod, feeMethod); - method.init(config); jest.spyOn(feeMethod, 'payFee'); }); @@ -760,4 +741,287 @@ describe('NFTMethod', () => { expect(lockingModule).toEqual(NFT_NOT_LOCKED); }); }); + + describe('transfer', () => { + const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + + it('should throw and emit error transfer event if nft does not exist', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, nftID), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw and emit error transfer event if nft is escrowed', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, escrowedNFT.nftID), + ).rejects.toThrow('NFT is escrowed to another chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID: escrowedNFT.nftID, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should throw and emit error transfer event if transfer is not initiated by the nft owner', async () => { + await expect( + method.transfer(methodContext, senderAddress, recipientAddress, existingNFT.nftID), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress, + recipientAddress, + nftID: existingNFT.nftID, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should throw and emit error transfer event if nft is locked', async () => { + await expect( + method.transfer( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + ), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + nftID: lockedExistingNFT.nftID, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should resolve if all params are valid', async () => { + jest.spyOn(internalMethod, 'transferInternal'); + + await expect( + method.transfer(methodContext, existingNFT.owner, recipientAddress, existingNFT.nftID), + ).resolves.toBeUndefined(); + expect(internalMethod['transferInternal']).toHaveBeenCalledWith( + methodContext, + recipientAddress, + existingNFT.nftID, + ); + }); + }); + + describe('transferCrossChain', () => { + const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); + const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); + const messageFee = BigInt(1000); + const data = ''; + const includeAttributes = false; + + it('should throw and emit error transfer cross chain event if nft does not exist', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT substore entry does not exist'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_DOES_NOT_EXIST, + ); + }); + + it('should throw and emit error transfer cross chain event if nft is escrowed', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + escrowedNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT is escrowed to another chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID: escrowedNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_ESCROWED, + ); + }); + + it('should throw and emit error transfer cross chain event if transfer is not initiated by the nft owner', async () => { + await expect( + method.transferCrossChain( + methodContext, + senderAddress, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Transfer not initiated by the NFT owner'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID: existingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_INITIATED_BY_NONOWNER, + ); + }); + + it('should throw and emit error transfer cross chain event if nft is locked', async () => { + await expect( + method.transferCrossChain( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Locked NFTs cannot be transferred'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + receivingChainID, + nftID: lockedExistingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_LOCKED, + ); + }); + + it('should throw and emit error transfer cross chain event if balance is less than message fee', async () => { + when(tokenMethod.getAvailableBalance) + .calledWith(methodContext, existingNFT.owner, messageFeeTokenID) + .mockResolvedValue(messageFee - BigInt(10)); + + await expect( + method.transferCrossChain( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('Insufficient balance for the message fee'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: existingNFT.owner, + recipientAddress, + receivingChainID, + nftID: existingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_INSUFFICIENT_BALANCE, + ); + }); + + it('should resolve if all params are valid', async () => { + jest.spyOn(internalMethod, 'transferCrossChainInternal'); + when(tokenMethod.getAvailableBalance) + .calledWith(methodContext, existingNFT.owner, messageFeeTokenID) + .mockResolvedValue(messageFee + BigInt(10)); + + await expect( + method.transferCrossChain( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).resolves.toBeUndefined(); + expect(internalMethod['transferCrossChainInternal']).toHaveBeenCalledWith( + methodContext, + existingNFT.owner, + recipientAddress, + existingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ); + }); + }); }); From 478b5c71debbd0e63d477091897bf6e3b99245e0 Mon Sep 17 00:00:00 2001 From: Incede <33103370+Incede@users.noreply.github.com> Date: Thu, 15 Jun 2023 00:41:59 +0200 Subject: [PATCH 2/2] Update verification per feedback --- framework/src/modules/nft/method.ts | 17 +++++++++ .../test/unit/modules/nft/method.spec.ts | 38 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/framework/src/modules/nft/method.ts b/framework/src/modules/nft/method.ts index 6026ebf20bb..7e77f8a95f0 100644 --- a/framework/src/modules/nft/method.ts +++ b/framework/src/modules/nft/method.ts @@ -558,6 +558,23 @@ export class NFTMethod extends BaseMethod { throw new Error('NFT is escrowed to another chain'); } + const nftChainID = this.getChainID(nftID); + const ownChainID = this._internalMethod.getOwnChainID(); + if (![ownChainID, receivingChainID].some(allowedChainID => nftChainID.equals(allowedChainID))) { + this.events.get(TransferCrossChainEvent).error( + methodContext, + { + senderAddress, + recipientAddress, + receivingChainID, + nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_NOT_NATIVE, + ); + throw new Error('NFT must be native either to the sending chain or the receiving chain'); + } + if (!owner.equals(senderAddress)) { this.events.get(TransferCrossChainEvent).error( methodContext, diff --git a/framework/test/unit/modules/nft/method.spec.ts b/framework/test/unit/modules/nft/method.spec.ts index d4b5a78438e..301c8151382 100644 --- a/framework/test/unit/modules/nft/method.spec.ts +++ b/framework/test/unit/modules/nft/method.spec.ts @@ -840,12 +840,17 @@ describe('NFTMethod', () => { describe('transferCrossChain', () => { const senderAddress = utils.getRandomBytes(LENGTH_ADDRESS); const recipientAddress = utils.getRandomBytes(LENGTH_ADDRESS); - const receivingChainID = utils.getRandomBytes(LENGTH_CHAIN_ID); const messageFee = BigInt(1000); const data = ''; const includeAttributes = false; + let receivingChainID: Buffer; + + beforeEach(() => { + receivingChainID = existingNFT.nftID.slice(0, LENGTH_CHAIN_ID); + }); it('should throw and emit error transfer cross chain event if nft does not exist', async () => { + receivingChainID = nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, @@ -875,6 +880,7 @@ describe('NFTMethod', () => { }); it('should throw and emit error transfer cross chain event if nft is escrowed', async () => { + receivingChainID = escrowedNFT.nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext, @@ -903,6 +909,35 @@ describe('NFTMethod', () => { ); }); + it('should throw and emit error transfer cross chain event if nft chain id is equal to neither own chain id or receiving chain id', async () => { + await expect( + method.transferCrossChain( + methodContext, + lockedExistingNFT.owner, + recipientAddress, + lockedExistingNFT.nftID, + receivingChainID, + messageFee, + data, + includeAttributes, + ), + ).rejects.toThrow('NFT must be native either to the sending chain or the receiving chain'); + checkEventResult( + methodContext.eventQueue, + 1, + TransferCrossChainEvent, + 0, + { + senderAddress: lockedExistingNFT.owner, + recipientAddress, + receivingChainID, + nftID: lockedExistingNFT.nftID, + includeAttributes, + }, + NftEventResult.RESULT_NFT_NOT_NATIVE, + ); + }); + it('should throw and emit error transfer cross chain event if transfer is not initiated by the nft owner', async () => { await expect( method.transferCrossChain( @@ -933,6 +968,7 @@ describe('NFTMethod', () => { }); it('should throw and emit error transfer cross chain event if nft is locked', async () => { + receivingChainID = lockedExistingNFT.nftID.slice(0, LENGTH_CHAIN_ID); await expect( method.transferCrossChain( methodContext,