diff --git a/src/api/entities/Instruction/__tests__/index.ts b/src/api/entities/Instruction/__tests__/index.ts index 61db5c23d2..748d2aa5f6 100644 --- a/src/api/entities/Instruction/__tests__/index.ts +++ b/src/api/entities/Instruction/__tests__/index.ts @@ -1,9 +1,10 @@ import { u64 } from '@polkadot/types'; import BigNumber from 'bignumber.js'; -import sinon from 'sinon'; +import sinon, { SinonStub } from 'sinon'; import { Entity, Instruction } from '~/api/entities'; -import { Context } from '~/base'; +import { Params, rejectInstruction } from '~/api/procedures/rejectInstruction'; +import { Context, TransactionQueue } from '~/base'; import { dsMockUtils, entityMockUtils } from '~/testUtils/mocks'; import { Mocked } from '~/testUtils/types'; import { InstructionStatus, InstructionType } from '~/types'; @@ -22,6 +23,10 @@ jest.mock( describe('Instruction class', () => { let context: Mocked; let instruction: Instruction; + let prepareRejectInstructionStub: SinonStub< + [Params, Context], + Promise> + >; let id: BigNumber; @@ -171,4 +176,24 @@ describe('Instruction class', () => { expect(leg.token).toEqual(entityMockUtils.getSecurityTokenInstance()); }); }); + + describe('method: reject', () => { + beforeAll(() => { + prepareRejectInstructionStub = sinon.stub(rejectInstruction, 'prepare'); + }); + + afterAll(() => { + sinon.restore(); + }); + + test('should prepare the procedure and return the resulting transaction queue', async () => { + const expectedQueue = ('someQueue' as unknown) as TransactionQueue; + + prepareRejectInstructionStub.withArgs({ id }, context).resolves(expectedQueue); + + const queue = await instruction.reject(); + + expect(queue).toBe(expectedQueue); + }); + }); }); diff --git a/src/api/entities/Instruction/index.ts b/src/api/entities/Instruction/index.ts index 379784bf93..8c2f359dd1 100644 --- a/src/api/entities/Instruction/index.ts +++ b/src/api/entities/Instruction/index.ts @@ -1,7 +1,8 @@ import BigNumber from 'bignumber.js'; import { Entity, Identity, SecurityToken } from '~/api/entities'; -import { Context } from '~/base'; +import { rejectInstruction } from '~/api/procedures'; +import { Context, TransactionQueue } from '~/base'; import { balanceToBigNumber, identityIdToString, @@ -120,4 +121,15 @@ export class Instruction extends Entity { }; }); } + + /** + * Reject this instruction + * + * @note reject on `SettleOnAuthorization` will execute the settlement and it will fail immediately. + * @note reject on `SettleOnBlock` behaves just like unauthorize + */ + public reject(): Promise> { + const { id, context } = this; + return rejectInstruction.prepare({ id }, context); + } } diff --git a/src/api/procedures/__tests__/rejectInstruction.ts b/src/api/procedures/__tests__/rejectInstruction.ts new file mode 100644 index 0000000000..d8d64b84cf --- /dev/null +++ b/src/api/procedures/__tests__/rejectInstruction.ts @@ -0,0 +1,207 @@ +import { u64 } from '@polkadot/types'; +import BigNumber from 'bignumber.js'; +import { + AuthorizationStatus as MeshAuthorizationStatus, + PortfolioId as MeshPortfolioId, +} from 'polymesh-types/types'; +import sinon from 'sinon'; + +import { Params, prepareRejectInstruction } from '~/api/procedures/rejectInstruction'; +import { Context } from '~/base'; +import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks'; +import { Mocked } from '~/testUtils/types'; +import { + AuthorizationStatus, + InstructionDetails, + InstructionStatus, + InstructionType, +} from '~/types'; +import { PortfolioId } from '~/types/internal'; +import * as utilsModule from '~/utils'; + +jest.mock( + '~/api/entities/Instruction', + require('~/testUtils/mocks/entities').mockInstructionModule('~/api/entities/Instruction') +); + +describe('rejectInstruction procedure', () => { + const id = new BigNumber(1); + const rawInstructionId = dsMockUtils.createMockU64(1); + const rawPortfolioId = dsMockUtils.createMockPortfolioId({ + did: dsMockUtils.createMockIdentityId('someDid'), + kind: dsMockUtils.createMockPortfolioKind('Default'), + }); + let mockContext: Mocked; + let numberToU64Stub: sinon.SinonStub<[number | BigNumber, Context], u64>; + let portfolioIdToMeshPortfolioIdStub: sinon.SinonStub<[PortfolioId, Context], MeshPortfolioId>; + let meshAuthorizationStatusToAuthorizationStatusStub: sinon.SinonStub< + [MeshAuthorizationStatus], + AuthorizationStatus + >; + + beforeAll(() => { + dsMockUtils.initMocks(); + procedureMockUtils.initMocks(); + entityMockUtils.initMocks(); + numberToU64Stub = sinon.stub(utilsModule, 'numberToU64'); + portfolioIdToMeshPortfolioIdStub = sinon.stub(utilsModule, 'portfolioIdToMeshPortfolioId'); + meshAuthorizationStatusToAuthorizationStatusStub = sinon.stub( + utilsModule, + 'meshAuthorizationStatusToAuthorizationStatus' + ); + }); + + let addTransactionStub: sinon.SinonStub; + + beforeEach(() => { + addTransactionStub = procedureMockUtils.getAddTransactionStub(); + mockContext = dsMockUtils.getContextInstance(); + numberToU64Stub.returns(rawInstructionId); + portfolioIdToMeshPortfolioIdStub.returns(rawPortfolioId); + }); + + afterEach(() => { + entityMockUtils.reset(); + procedureMockUtils.reset(); + dsMockUtils.reset(); + }); + + afterAll(() => { + entityMockUtils.cleanup(); + procedureMockUtils.cleanup(); + dsMockUtils.cleanup(); + }); + + test('should throw an error if instruction is not in pending state', () => { + entityMockUtils.configureMocks({ + instructionOptions: { + details: { + status: InstructionStatus.Unknown, + } as InstructionDetails, + }, + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + return expect( + prepareRejectInstruction.call(proc, { + id, + }) + ).rejects.toThrow('The Instruction must be in pending state'); + }); + + test('should throw an error if instruction is blocked', async () => { + const validFrom = new Date('12/12/2050'); + + entityMockUtils.configureMocks({ + instructionOptions: { + details: { + status: InstructionStatus.Pending, + validFrom, + } as InstructionDetails, + }, + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + let error; + + try { + await prepareRejectInstruction.call(proc, { + id, + }); + } catch (err) { + error = err; + } + + expect(error.message).toBe('The instruction has not reached its validity period'); + expect(error.data.validFrom).toBe(validFrom); + }); + + test('should throw an error if the instruction can not be modified', async () => { + const endBlock = new BigNumber(10); + + entityMockUtils.configureMocks({ + instructionOptions: { + details: { + status: InstructionStatus.Pending, + type: InstructionType.SettleOnBlock, + endBlock, + } as InstructionDetails, + }, + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + let error; + + try { + await prepareRejectInstruction.call(proc, { + id, + }); + } catch (err) { + error = err; + } + + expect(error.message).toBe( + 'The instruction cannot be modified; it has already reached its end block' + ); + expect(error.data.endBlock).toBe(endBlock); + }); + + test('should throw an error if authorization status is rejected', () => { + entityMockUtils.configureMocks({ + instructionOptions: { + details: { + status: InstructionStatus.Pending, + type: InstructionType.SettleOnBlock, + endBlock: new BigNumber(1000), + } as InstructionDetails, + }, + }); + + const rawAuthorizationStatus = dsMockUtils.createMockAuthorizationStatus('Rejected'); + dsMockUtils.createQueryStub('settlement', 'userAuths').resolves(rawAuthorizationStatus); + meshAuthorizationStatusToAuthorizationStatusStub + .withArgs(rawAuthorizationStatus) + .returns(AuthorizationStatus.Rejected); + + const proc = procedureMockUtils.getInstance(mockContext); + + return expect( + prepareRejectInstruction.call(proc, { + id, + }) + ).rejects.toThrow('The instruction cannot be rejected'); + }); + + /* + test('should add an authorize instruction transaction to the queue', async () => { + entityMockUtils.configureMocks({ + instructionOptions: { + details: { + status: InstructionStatus.Pending, + } as InstructionDetails, + }, + }); + + const rawAuthorizationStatus = dsMockUtils.createMockAuthorizationStatus('Pending'); + dsMockUtils.createQueryStub('settlement', 'userAuths').resolves(rawAuthorizationStatus); + meshAuthorizationStatusToAuthorizationStatusStub + .withArgs(rawAuthorizationStatus) + .returns(AuthorizationStatus.Pending); + + const proc = procedureMockUtils.getInstance(mockContext); + + const transaction = dsMockUtils.createTxStub('settlement', 'authorizeInstruction'); + + await prepareRejectInstruction.call(proc, { + id, + }); + + sinon.assert.calledWith(addTransactionStub, transaction, {}, rawInstructionId, [ + rawPortfolioId, + ]); + }); + */ +}); diff --git a/src/api/procedures/index.ts b/src/api/procedures/index.ts index 36a6885bdc..191452f112 100644 --- a/src/api/procedures/index.ts +++ b/src/api/procedures/index.ts @@ -33,3 +33,4 @@ export { ModifyPrimaryIssuanceAgentParams, } from './modifyPrimaryIssuanceAgent'; export { removePrimaryIssuanceAgent } from './removePrimaryIssuanceAgent'; +export { rejectInstruction } from './rejectInstruction'; diff --git a/src/api/procedures/rejectInstruction.ts b/src/api/procedures/rejectInstruction.ts new file mode 100644 index 0000000000..fc6f6778aa --- /dev/null +++ b/src/api/procedures/rejectInstruction.ts @@ -0,0 +1,100 @@ +import BigNumber from 'bignumber.js'; + +import { Instruction } from '~/api/entities'; +import { PolymeshError, Procedure } from '~/base'; +import { AuthorizationStatus, ErrorCode, InstructionStatus, InstructionType } from '~/types'; +import { + meshAuthorizationStatusToAuthorizationStatus, + numberToU64, + portfolioIdToMeshPortfolioId, +} from '~/utils'; + +/** + * @hidden + */ +export type Params = { id: BigNumber }; + +/** + * @hidden + */ +export async function prepareRejectInstruction( + this: Procedure, + args: Params +): Promise { + const { + context: { + polymeshApi: { + tx, + query: { settlement }, + }, + }, + context, + } = this; + + const { id } = args; + + const instruction = new Instruction({ id }, context); + + const [details, currentIdentity] = await Promise.all([ + instruction.details(), + context.getCurrentIdentity(), + ]); + + const { status, validFrom } = details; + + if (status !== InstructionStatus.Pending) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The Instruction must be in pending state', + }); + } + + if (validFrom) { + const now = new Date(); + + if (now < validFrom) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The instruction has not reached its validity period', + data: { + validFrom, + }, + }); + } + } + + if (details.type === InstructionType.SettleOnBlock) { + const latestBlock = await context.getLatestBlock(); + const { endBlock } = details; + + if (latestBlock >= endBlock) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The instruction cannot be modified; it has already reached its end block', + data: { + currentBlock: latestBlock, + endBlock, + }, + }); + } + } + + const rawInstructionId = numberToU64(id, context); + const rawPortfolioId = portfolioIdToMeshPortfolioId({ did: currentIdentity.did }, context); + const rawAuthorizationStatus = await settlement.userAuths(rawPortfolioId, rawInstructionId); + const authorizationStatus = meshAuthorizationStatusToAuthorizationStatus(rawAuthorizationStatus); + + if (authorizationStatus === AuthorizationStatus.Rejected) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'The instruction cannot be rejected', + }); + } + + this.addTransaction(tx.settlement.rejectInstruction, {}, rawInstructionId, [rawPortfolioId]); +} + +/** + * @hidden + */ +export const rejectInstruction = new Procedure(prepareRejectInstruction);