diff --git a/__tests__/unit/core-magistrate-transactions/handlers/entity.test.ts b/__tests__/unit/core-magistrate-transactions/handlers/entity.test.ts index 467765d697..e296d3ab3c 100644 --- a/__tests__/unit/core-magistrate-transactions/handlers/entity.test.ts +++ b/__tests__/unit/core-magistrate-transactions/handlers/entity.test.ts @@ -1,28 +1,28 @@ import "jest-extended"; -import { Container, Utils } from "@arkecosystem/core-kernel"; -import { Enums } from "@arkecosystem/core-magistrate-crypto"; -import { EntityBuilder } from "@arkecosystem/core-magistrate-crypto/src/builders"; -import { EntityType, EntityAction } from "@arkecosystem/core-magistrate-crypto/src/enums"; -import { EntityTransaction } from "@arkecosystem/core-magistrate-crypto/src/transactions"; +import { Container, Utils } from "@packages/core-kernel"; +import { PoolError } from "@packages/core-kernel/dist/contracts/transaction-pool"; +import { Enums } from "@packages/core-magistrate-crypto"; +import { EntityBuilder } from "@packages/core-magistrate-crypto/src/builders"; +import { EntityAction, EntityType } from "@packages/core-magistrate-crypto/src/enums"; +import { EntityTransaction } from "@packages/core-magistrate-crypto/src/transactions"; import { EntityAlreadyRegisteredError, EntityAlreadyResignedError, + EntityNameAlreadyRegisteredError, + EntityNameDoesNotMatchDelegateError, EntityNotRegisteredError, + EntitySenderIsNotDelegateError, EntityWrongSubTypeError, EntityWrongTypeError, StaticFeeMismatchError, - EntitySenderIsNotDelegateError, - EntityNameDoesNotMatchDelegateError, - EntityNameAlreadyRegisteredError, -} from "@arkecosystem/core-magistrate-transactions/src/errors"; -import { EntityTransactionHandler } from "@arkecosystem/core-magistrate-transactions/src/handlers/entity"; -import { Utils as CryptoUtils } from "@arkecosystem/crypto"; -import { Managers, Transactions } from "@arkecosystem/crypto"; - -import { validRegisters } from "./__fixtures__/entity/register"; -import { validResigns } from "./__fixtures__/entity/resign"; -import { validUpdates } from "./__fixtures__/entity/update"; +} from "@packages/core-magistrate-transactions/src/errors"; +import { EntityTransactionHandler } from "@packages/core-magistrate-transactions/src/handlers/entity"; +import { Utils as CryptoUtils } from "@packages/crypto"; +import { Managers, Transactions } from "@packages/crypto"; +import { cloneDeep } from "lodash"; + +import { validRegisters, validResigns, validUpdates } from "./__fixtures__/entity"; import { walletRepository } from "./__mocks__/wallet-repository"; // mocking the abstract TransactionHandler class @@ -52,31 +52,39 @@ describe("Entity handler", () => { streamByCriteria: jest.fn(), }; + const poolQuery = { + getAll: jest.fn(), + whereKind: jest.fn(), + wherePredicate: jest.fn(), + has: jest.fn().mockReturnValue(false), + }; + + poolQuery.getAll.mockReturnValue(poolQuery); + poolQuery.whereKind.mockReturnValue(poolQuery); + poolQuery.wherePredicate.mockReturnValue(poolQuery); + beforeAll(() => { container.unbindAll(); container.bind(Container.Identifiers.TransactionHistoryService).toConstantValue(transactionHistoryService); + container.bind(Container.Identifiers.TransactionPoolQuery).toConstantValue(poolQuery); }); let wallet, walletAttributes; beforeEach(() => { walletAttributes = {}; wallet = { - getAttribute: jest - .fn() - .mockImplementation((attribute, defaultValue) => { - const splitAttribute = attribute.split("."); - return splitAttribute.length === 1 - ? walletAttributes[splitAttribute[0]] || defaultValue - : (walletAttributes[splitAttribute[0]] || {})[splitAttribute[1]] || defaultValue; - }), - hasAttribute: jest - .fn() - .mockImplementation((attribute) => { - const splitAttribute = attribute.split("."); - return splitAttribute.length === 1 - ? !!walletAttributes[splitAttribute[0]] - : !!walletAttributes[splitAttribute[0]] && !!walletAttributes[splitAttribute[0]][splitAttribute[1]]; - }), + getAttribute: jest.fn().mockImplementation((attribute, defaultValue) => { + const splitAttribute = attribute.split("."); + return splitAttribute.length === 1 + ? walletAttributes[splitAttribute[0]] || defaultValue + : (walletAttributes[splitAttribute[0]] || {})[splitAttribute[1]] || defaultValue; + }), + hasAttribute: jest.fn().mockImplementation((attribute) => { + const splitAttribute = attribute.split("."); + return splitAttribute.length === 1 + ? !!walletAttributes[splitAttribute[0]] + : !!walletAttributes[splitAttribute[0]] && !!walletAttributes[splitAttribute[0]][splitAttribute[1]]; + }), setAttribute: jest.fn().mockImplementation((attribute, value) => (walletAttributes[attribute] = value)), forgetAttribute: jest.fn().mockImplementation((attribute) => delete walletAttributes[attribute]), }; @@ -86,6 +94,10 @@ describe("Entity handler", () => { entityHandler = container.resolve(EntityTransactionHandler); }); + afterEach(() => { + jest.clearAllMocks(); + }); + const registerFee = "5000000000"; const updateAndResignFee = "500000000"; @@ -121,15 +133,14 @@ describe("Entity handler", () => { }); describe("dynamicFee", () => { - const registerTx = (new EntityBuilder()).asset(validRegisters[0]).sign("passphrase").build(); - const updateTx = (new EntityBuilder()).asset(validUpdates[0]).sign("passphrase").build(); - const resignTx = (new EntityBuilder()).asset(validResigns[0]).sign("passphrase").build(); + const registerTx = new EntityBuilder().asset(validRegisters[0]).sign("passphrase").build(); + const updateTx = new EntityBuilder().asset(validUpdates[0]).sign("passphrase").build(); + const resignTx = new EntityBuilder().asset(validResigns[0]).sign("passphrase").build(); it.each([ [registerTx, registerFee], [updateTx, updateAndResignFee], [resignTx, updateAndResignFee], ])("should return correct static fee", async (tx, fee) => { - entityHandler = container.resolve(EntityTransactionHandler); // @ts-ignore const result = await entityHandler.dynamicFee({ transaction: tx }); @@ -169,6 +180,54 @@ describe("Entity handler", () => { }); }); + describe("throwIfCannotEnterPool", () => { + let transaction: EntityTransaction; + let entityHandler: EntityTransactionHandler; + + beforeEach(() => { + const builder = new EntityBuilder(); + transaction = builder.asset(validRegisters[0]).sign("passphrase").build() as EntityTransaction; + + entityHandler = container.resolve(EntityTransactionHandler); + }); + + it("should resolve", async () => { + await expect(entityHandler.throwIfCannotEnterPool(transaction)).toResolve(); + expect(poolQuery.getAll).toHaveBeenCalled(); + }); + + it("should resolve if transaction action is update or resign", async () => { + transaction.data.asset!.action = Enums.EntityAction.Update; + + await expect(entityHandler.throwIfCannotEnterPool(transaction)).toResolve(); + expect(poolQuery.getAll).not.toHaveBeenCalled(); + + transaction.data.asset!.action = Enums.EntityAction.Resign; + + await expect(entityHandler.throwIfCannotEnterPool(transaction)).toResolve(); + expect(poolQuery.getAll).not.toHaveBeenCalled(); + }); + + it("should throw if transaction with same type and name is already in the pool", async () => { + poolQuery.has.mockReturnValue(true); + + await expect(entityHandler.throwIfCannotEnterPool(transaction)).rejects.toBeInstanceOf(PoolError); + + expect(poolQuery.wherePredicate).toHaveBeenCalledTimes(2); + + const transactionClone = cloneDeep(transaction); + const wherePredicateCallback1 = poolQuery.wherePredicate.mock.calls[0][0]; + const wherePredicateCallback2 = poolQuery.wherePredicate.mock.calls[1][0]; + + expect(wherePredicateCallback1(transactionClone)).toBeTrue(); + expect(wherePredicateCallback2(transactionClone)).toBeTrue(); + + delete transactionClone.data.asset; + expect(wherePredicateCallback1(transactionClone)).toBeFalse(); + expect(wherePredicateCallback2(transactionClone)).toBeFalse(); + }); + }); + describe("throwIfCannotBeApplied", () => { let transaction: EntityTransaction; let entityHandler: EntityTransactionHandler; @@ -338,47 +397,55 @@ describe("Entity handler", () => { ); }); - it.each([validRegisters])("should throw when entity name is already registered for same type", async (asset) => { - const builder = new EntityBuilder(); - const transaction = builder.asset(asset).sign("passphrase").build(); - - const walletSameEntityName = { - hasAttribute: () => true, - getAttribute: () => ({ - "7950c6a0d096eeb4883237feec12b9f37f36ab9343ff3640904befc75ce32ec2": { - type: asset.type, - subType: (asset.subType + 1) % 255, // different subType but still in the range [0, 255] - data: asset.data, - }}), - } - //@ts-ignore - jest.spyOn(walletRepository, "getIndex").mockReturnValueOnce([walletSameEntityName]); - - entityHandler = container.resolve(EntityTransactionHandler); - await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).rejects.toBeInstanceOf( - EntityNameAlreadyRegisteredError, - ); - }); - - it.each([validRegisters])("should not throw when entity name is registered for a different type", async (asset) => { - const builder = new EntityBuilder(); - const transaction = builder.asset(asset).sign("passphrase").build(); + it.each([validRegisters])( + "should throw when entity name is already registered for same type", + async (asset) => { + const builder = new EntityBuilder(); + const transaction = builder.asset(asset).sign("passphrase").build(); + + const walletSameEntityName = { + hasAttribute: () => true, + getAttribute: () => ({ + "7950c6a0d096eeb4883237feec12b9f37f36ab9343ff3640904befc75ce32ec2": { + type: asset.type, + subType: (asset.subType + 1) % 255, // different subType but still in the range [0, 255] + data: asset.data, + }, + }), + }; + //@ts-ignore + jest.spyOn(walletRepository, "getIndex").mockReturnValueOnce([walletSameEntityName]); + + entityHandler = container.resolve(EntityTransactionHandler); + await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).rejects.toBeInstanceOf( + EntityNameAlreadyRegisteredError, + ); + }, + ); - const walletSameEntityName = { - hasAttribute: () => true, - getAttribute: () => ({ - "7950c6a0d096eeb4883237feec12b9f37f36ab9343ff3640904befc75ce32ec2": { - type: (asset.type + 1) % 255, // different type but still in the range [0, 255] - subType: asset.subType, - data: asset.data, - }}), - } - //@ts-ignore - jest.spyOn(walletRepository, "getIndex").mockReturnValueOnce([walletSameEntityName]); + it.each([validRegisters])( + "should not throw when entity name is registered for a different type", + async (asset) => { + const builder = new EntityBuilder(); + const transaction = builder.asset(asset).sign("passphrase").build(); + + const walletSameEntityName = { + hasAttribute: () => true, + getAttribute: () => ({ + "7950c6a0d096eeb4883237feec12b9f37f36ab9343ff3640904befc75ce32ec2": { + type: (asset.type + 1) % 255, // different type but still in the range [0, 255] + subType: asset.subType, + data: asset.data, + }, + }), + }; + //@ts-ignore + jest.spyOn(walletRepository, "getIndex").mockReturnValueOnce([walletSameEntityName]); - entityHandler = container.resolve(EntityTransactionHandler); - await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).toResolve(); - }); + entityHandler = container.resolve(EntityTransactionHandler); + await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).toResolve(); + }, + ); describe("Entity delegate", () => { const entityId = "533384534cd561fc17f72be0bb57bf39961954ba0741f53c08e3f463ef19118c"; @@ -394,7 +461,7 @@ describe("Entity handler", () => { asset.data = { ipfsData: "Qmbw6QmF6tuZpyV6WyEsTmExkEG3rW4khbttQidPfbpmNZ" }; } - return (new EntityBuilder()) + return new EntityBuilder() .asset(asset) .fee(action === EntityAction.Register ? registerFee : updateAndResignFee) .sign("passphrase") @@ -404,9 +471,9 @@ describe("Entity handler", () => { it("should throw when the sender wallet is not a delegate", async () => { const transaction = createEntityDelegateTx("anyname"); - await expect( - entityHandler.throwIfCannotBeApplied(transaction, wallet), - ).rejects.toBeInstanceOf(EntitySenderIsNotDelegateError); + await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).rejects.toBeInstanceOf( + EntitySenderIsNotDelegateError, + ); }); it("should throw when the sender delegate name does not match the entity name", async () => { @@ -415,9 +482,9 @@ describe("Entity handler", () => { walletAttributes.delegate = { username: username + "s" }; - await expect( - entityHandler.throwIfCannotBeApplied(transaction, wallet), - ).rejects.toBeInstanceOf(EntityNameDoesNotMatchDelegateError); + await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).rejects.toBeInstanceOf( + EntityNameDoesNotMatchDelegateError, + ); }); it("should not throw on update or resign even when delegate does not match", async () => { @@ -431,12 +498,8 @@ describe("Entity handler", () => { [entityId]: { name: "somename", type, subType, data: {} }, }; - await expect( - entityHandler.throwIfCannotBeApplied(transactionResign, wallet), - ).toResolve(); - await expect( - entityHandler.throwIfCannotBeApplied(transactionUpdate, wallet), - ).toResolve(); + await expect(entityHandler.throwIfCannotBeApplied(transactionResign, wallet)).toResolve(); + await expect(entityHandler.throwIfCannotBeApplied(transactionUpdate, wallet)).toResolve(); }); it("should not throw otherwise", async () => { @@ -445,11 +508,9 @@ describe("Entity handler", () => { walletAttributes.delegate = { username }; - await expect( - entityHandler.throwIfCannotBeApplied(transaction, wallet), - ).toResolve(); + await expect(entityHandler.throwIfCannotBeApplied(transaction, wallet)).toResolve(); }); - }) + }); }); }); diff --git a/packages/core-magistrate-transactions/src/handlers/entity.ts b/packages/core-magistrate-transactions/src/handlers/entity.ts index d12d383f11..51891caa38 100644 --- a/packages/core-magistrate-transactions/src/handlers/entity.ts +++ b/packages/core-magistrate-transactions/src/handlers/entity.ts @@ -23,8 +23,11 @@ import { MagistrateIndex } from "../wallet-indexes"; @Container.injectable() export class EntityTransactionHandler extends Handlers.TransactionHandler { + @Container.inject(Container.Identifiers.TransactionPoolQuery) + private readonly poolQuery!: Contracts.TransactionPool.Query; + @Container.inject(Container.Identifiers.TransactionHistoryService) - protected readonly transactionHistoryService!: Contracts.Shared.TransactionHistoryService; + private readonly transactionHistoryService!: Contracts.Shared.TransactionHistoryService; public dependencies(): ReadonlyArray { return []; @@ -61,6 +64,29 @@ export class EntityTransactionHandler extends Handlers.TransactionHandler { } } + public async throwIfCannotEnterPool(transaction: Interfaces.ITransaction): Promise { + KernelUtils.assert.defined(transaction.data.asset); + + if (transaction.data.asset.action === Enums.EntityAction.Register) { + KernelUtils.assert.defined(transaction.data.asset.data.name); + const name = transaction.data.asset.data.name; + + const hasName: boolean = this.poolQuery + .getAll() + .whereKind(transaction) + .wherePredicate((t) => t.data.asset?.type === transaction.data.asset!.type) + .wherePredicate((t) => t.data.asset?.data.name.toLowerCase() === name.toLowerCase()) + .has(); + + if (hasName) { + throw new Contracts.TransactionPool.PoolError( + `Entity registration for "${name}" already in the pool`, + "ERR_PENDING", + ); + } + } + } + public async throwIfCannotBeApplied( transaction: CryptoInterfaces.ITransaction, wallet: Contracts.State.Wallet,