diff --git a/src/procedures/TransferSecurityTokens.ts b/src/procedures/TransferSecurityTokens.ts new file mode 100644 index 0000000..d381d22 --- /dev/null +++ b/src/procedures/TransferSecurityTokens.ts @@ -0,0 +1,93 @@ +import { TransferStatusCode } from '@polymathnetwork/contract-wrappers'; +import { Procedure } from './Procedure'; +import { + TransferSecurityTokensProcedureArgs, + ErrorCode, + ProcedureType, + PolyTransactionTag, +} from '../types'; +import { PolymathError } from '../PolymathError'; +import { SecurityToken, Shareholder } from '../entities'; +import { Factories } from '../Context'; + +/** + * Procedure to transfer security tokens. + */ +export class TransferSecurityTokens extends Procedure { + public type = ProcedureType.TransferSecurityTokens; + + private checkTransferStatus( + statusCode: TransferStatusCode, + fromAddress: string, + symbol: string, + to: string, + reasonCode: string + ) { + if (statusCode !== TransferStatusCode.TransferSuccess) { + throw new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: `[${statusCode}] ${fromAddress} is not allowed to transfer ${symbol} to ${to}. Possible reason: ${reasonCode}`, + }); + } + } + + public async prepareTransactions() { + const { symbol, to, amount, data = '', from } = this.args; + const { contractWrappers, currentWallet, factories } = this.context; + + let securityToken; + + try { + securityToken = await contractWrappers.tokenFactory.getSecurityTokenInstanceFromTicker( + symbol + ); + } catch (err) { + throw new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: `There is no Security Token with symbol ${symbol}`, + }); + } + + const fromAddress = await currentWallet.address(); + + if (from && from !== fromAddress) { + const { statusCode, reasonCode } = await securityToken.canTransferFrom({ + to, + value: amount, + from, + }); + this.checkTransferStatus(statusCode, from, symbol, to, reasonCode); + } else { + const { statusCode, reasonCode } = await securityToken.canTransfer({ to, value: amount }); + this.checkTransferStatus(statusCode, fromAddress, symbol, to, reasonCode); + } + + await this.addTransaction(securityToken.transferFromWithData, { + tag: PolyTransactionTag.TransferSecurityTokens, + resolvers: [createTransferSecurityTokensResolver(factories, symbol, from || fromAddress, to)], + })({ from: from || fromAddress, to, value: amount, data }); + } +} + +export const createTransferSecurityTokensResolver = ( + factories: Factories, + symbol: string, + from: string, + to: string +) => async () => { + const refreshingFrom = factories.shareholderFactory.refresh( + Shareholder.generateId({ + securityTokenId: SecurityToken.generateId({ symbol }), + address: from, + }) + ); + + const refreshingTo = factories.shareholderFactory.refresh( + Shareholder.generateId({ + securityTokenId: SecurityToken.generateId({ symbol }), + address: to, + }) + ); + + return Promise.all([refreshingFrom, refreshingTo]); +}; diff --git a/src/procedures/__tests__/TransferSecurityTokens.ts b/src/procedures/__tests__/TransferSecurityTokens.ts new file mode 100644 index 0000000..f81a485 --- /dev/null +++ b/src/procedures/__tests__/TransferSecurityTokens.ts @@ -0,0 +1,178 @@ +import { ImportMock, MockManager } from 'ts-mock-imports'; +import { spy, restore } from 'sinon'; +import { BigNumber, TransferStatusCode } from '@polymathnetwork/contract-wrappers'; +import * as contractWrappersModule from '@polymathnetwork/contract-wrappers'; +import { TransferSecurityTokens } from '../../procedures/TransferSecurityTokens'; +import { Procedure } from '../../procedures/Procedure'; +import * as transferSecurityTokensModule from '../../procedures/TransferSecurityTokens'; +import { PolymathError } from '../../PolymathError'; +import { + ErrorCode, + TransferSecurityTokensProcedureArgs, + PolyTransactionTag, + ProcedureType, +} from '../../types'; +import * as shareholderFactoryModule from '../../entities/factories/ShareholderFactory'; +import * as contextModule from '../../Context'; +import * as wrappersModule from '../../PolymathBase'; +import * as tokenFactoryModule from '../../testUtils/MockedTokenFactoryModule'; +import { mockFactories } from '../../testUtils/mockFactories'; +import { Shareholder } from '../../entities'; +import { SecurityToken } from '../../entities/SecurityToken/SecurityToken'; +import { Wallet } from '../../Wallet'; +import { Factories } from '../../Context'; + +const params: TransferSecurityTokensProcedureArgs = { + symbol: 'TEST', + to: '0x8b0EC3e41C7710765675963bD692cbBDC6De8670', + amount: new BigNumber(100), +}; + +describe('TransferSecurityTokens', () => { + let target: TransferSecurityTokens; + let contextMock: MockManager; + let wrappersMock: MockManager; + let tokenFactoryMock: MockManager; + let securityTokenMock: MockManager; + let shareholderFactoryMock: MockManager; + let factoriesMockedSetup: Factories; + + beforeEach(() => { + contextMock = ImportMock.mockClass(contextModule, 'Context'); + wrappersMock = ImportMock.mockClass(wrappersModule, 'PolymathBase'); + tokenFactoryMock = ImportMock.mockClass(tokenFactoryModule, 'MockedTokenFactoryModule'); + + contextMock.set('contractWrappers', wrappersMock.getMockInstance()); + wrappersMock.set('tokenFactory', tokenFactoryMock.getMockInstance()); + + securityTokenMock = ImportMock.mockClass(contractWrappersModule, 'SecurityToken_3_0_0'); + + tokenFactoryMock.mock( + 'getSecurityTokenInstanceFromTicker', + securityTokenMock.getMockInstance() + ); + + shareholderFactoryMock = ImportMock.mockClass(shareholderFactoryModule, 'ShareholderFactory'); + factoriesMockedSetup = mockFactories(); + factoriesMockedSetup.shareholderFactory = shareholderFactoryMock.getMockInstance(); + contextMock.set('factories', factoriesMockedSetup); + + // Instantiate TransferSecurityTokens + target = new TransferSecurityTokens(params, contextMock.getMockInstance()); + }); + + afterEach(() => { + restore(); + }); + + describe('Types', () => { + test('should extend procedure and have TransferSecurityTokens type', async () => { + expect(target instanceof Procedure).toBe(true); + expect(target.type).toBe(ProcedureType.TransferSecurityTokens); + }); + }); + + describe('TransferSecurityTokens', () => { + test('should throw if there is no valid security token supplied', async () => { + tokenFactoryMock + .mock('getSecurityTokenInstanceFromTicker') + .withArgs(params.symbol) + .throws(); + + await expect(target.prepareTransactions()).rejects.toThrow( + new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: `There is no Security Token with symbol ${params.symbol}`, + }) + ); + }); + }); + + test('should add a transaction to the queue to execute a transfer security token using a different sender address', async () => { + target = new TransferSecurityTokens( + { ...params, from: '0x1FB52cef867d95E69d398Fe9F6486fAF92C7ED7F' }, + contextMock.getMockInstance() + ); + contextMock.set( + 'currentWallet', + new Wallet({ address: () => Promise.resolve('0x0e6b236a504fce78527497e46dc90c0a6fdc9495') }) + ); + + securityTokenMock.mock( + 'canTransferFrom', + Promise.resolve({ + statusCode: TransferStatusCode.TransferSuccess, + }) + ); + + const addTransactionSpy = spy(target, 'addTransaction'); + securityTokenMock.mock('transferFromWithData', Promise.resolve('TransferFromWithData')); + + await target.prepareTransactions(); + + expect( + addTransactionSpy + .getCall(0) + .calledWith(securityTokenMock.getMockInstance().transferFromWithData) + ).toEqual(true); + expect(addTransactionSpy.getCall(0).lastArg.tag).toEqual( + PolyTransactionTag.TransferSecurityTokens + ); + expect(addTransactionSpy.callCount).toEqual(1); + }); + + test('should throw error if canTransferFrom method returns status code different than success', async () => { + const from = '0x1FB52cef867d95E69d398Fe9F6486fAF92C7ED7F'; + const reasonCode = '0x50'; + + target = new TransferSecurityTokens({ ...params, from }, contextMock.getMockInstance()); + contextMock.set('currentWallet', new Wallet({ address: () => Promise.resolve(from) })); + + securityTokenMock.mock( + 'canTransfer', + Promise.resolve({ + statusCode: TransferStatusCode.TransferFailure, + reasonCode, + }) + ); + + await expect(target.prepareTransactions()).rejects.toThrowError( + new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: `[${TransferStatusCode.TransferFailure}] ${from} is not allowed to transfer ${ + params.symbol + } to ${params.to}. Possible reason: ${reasonCode}`, + }) + ); + }); + + test('should successfully refresh the corresponding balance of each shareholder involved', async () => { + const from = '0x1FB52cef867d95E69d398Fe9F6486fAF92C7ED7F'; + const refreshStub = shareholderFactoryMock.mock('refresh', Promise.resolve()); + const securityTokenId = SecurityToken.generateId({ symbol: params.symbol }); + const resolverValue = await transferSecurityTokensModule.createTransferSecurityTokensResolver( + factoriesMockedSetup, + params.symbol, + from, + params.to + )(); + expect( + refreshStub.getCall(0).calledWithExactly( + Shareholder.generateId({ + securityTokenId, + address: from, + }) + ) + ).toEqual(true); + expect( + refreshStub.getCall(1).calledWithExactly( + Shareholder.generateId({ + securityTokenId, + address: params.to, + }) + ) + ).toEqual(true); + expect(resolverValue).toEqual([undefined, undefined]); + expect(refreshStub.callCount).toEqual(2); + }); +}); diff --git a/src/procedures/index.ts b/src/procedures/index.ts index 4e09c84..2edfb63 100644 --- a/src/procedures/index.ts +++ b/src/procedures/index.ts @@ -35,5 +35,6 @@ export { ModifyMaxHolderCount } from './ModifyMaxHolderCount'; export { EnablePercentageTransferManager } from './EnablePercentageTransferManager'; export { ModifyMaxHolderPercentage } from './ModifyMaxHolderPercentage'; export { ModifyPercentageExemptions } from './ModifyPercentageExemptions'; +export { TransferSecurityTokens } from './TransferSecurityTokens'; export { ToggleFreezeTransfers } from './ToggleFreezeTransfers'; export { ModifyDividendsDefaultExclusionList } from './ModifyDividendsDefaultExclusionList'; diff --git a/src/types/index.ts b/src/types/index.ts index c5b2606..eadbecd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -164,6 +164,7 @@ export enum ProcedureType { ModifyMaxHolderCount = 'ModifyMaxHolderCount', ModifyMaxHolderPercentage = 'ModifyMaxHolderPercentage', ModifyPercentageExemptions = 'ModifyPercentageExemptions', + TransferSecurityTokens = 'TransferSecurityTokens', ToggleFreezeTransfers = 'ToggleFreezeTransfers', } @@ -220,6 +221,7 @@ export enum PolyTransactionTag { ChangeHolderPercentage = 'ChangeHolderPercentage', ModifyWhitelistMulti = 'ModifyWhitelistMulti', SetAllowPrimaryIssuance = 'SetAllowPrimaryIssuance', + TransferSecurityTokens = 'TransferSecurityTokens', UnfreezeTransfers = 'UnfreezeTransfers', FreezeTransfers = 'FreezeTransfers', Signature = 'Signature', @@ -582,6 +584,14 @@ export interface ModifyPercentageExemptionsProcedureArgs { allowPrimaryIssuance?: boolean; } +export interface TransferSecurityTokensProcedureArgs { + symbol: string; + to: string; + amount: BigNumber; + data?: string; + from?: string; +} + export interface ToggleFreezeTransfersProcedureArgs { symbol: string; freeze: boolean; @@ -629,6 +639,7 @@ export interface ProcedureArguments { [ProcedureType.ModifyMaxHolderCount]: ModifyMaxHolderCountProcedureArgs; [ProcedureType.ModifyMaxHolderPercentage]: ModifyMaxHolderPercentageProcedureArgs; [ProcedureType.ModifyPercentageExemptions]: ModifyPercentageExemptionsProcedureArgs; + [ProcedureType.TransferSecurityTokens]: TransferSecurityTokensProcedureArgs; [ProcedureType.ToggleFreezeTransfers]: ToggleFreezeTransfersProcedureArgs; [ProcedureType.UnnamedProcedure]: {}; }