diff --git a/src/procedures/TransferErc20.ts b/src/procedures/TransferErc20.ts index ab2ca21..6d04649 100644 --- a/src/procedures/TransferErc20.ts +++ b/src/procedures/TransferErc20.ts @@ -3,6 +3,7 @@ import { Procedure } from './Procedure'; import { TransferErc20ProcedureArgs, ErrorCode, ProcedureType, PolyTransactionTag } from '../types'; import { PolymathError } from '../PolymathError'; import { Erc20TokenBalance } from '../entities'; +import { Factories } from '~/Context'; /** * Procedure to transfer funds of an ERC20 token. If no token address is specified, it defaults to POLY @@ -34,7 +35,9 @@ export class TransferErc20 extends Procedure { } try { - token = await contractWrappers.getERC20TokenWrapper({ address: tokenAddress }); + token = await contractWrappers.getERC20TokenWrapper({ + address: tokenAddress, + }); } catch (err) { throw new PolymathError({ code: ErrorCode.ProcedureValidationError, @@ -70,11 +73,19 @@ export class TransferErc20 extends Procedure { await this.addTransaction(token.transfer, { tag: PolyTransactionTag.TransferErc20, - resolver: async _receipt => { - return factories.erc20TokenBalanceFactory.refresh( - Erc20TokenBalance.generateId({ tokenAddress: address, walletAddress: receiver }) - ); - }, + resolver: createTransferErc20Resolver(factories, address, receiver), })({ to: receiver, value: amount }); } } +export const createTransferErc20Resolver = ( + factories: Factories, + tokenAddress: string, + receiver: string +) => async () => { + return factories.erc20TokenBalanceFactory.refresh( + Erc20TokenBalance.generateId({ + tokenAddress, + walletAddress: receiver, + }) + ); +}; diff --git a/src/procedures/__tests__/AssignSecurityTokenRole.ts b/src/procedures/__tests__/AssignSecurityTokenRole.ts index 56066e2..0bebd87 100644 --- a/src/procedures/__tests__/AssignSecurityTokenRole.ts +++ b/src/procedures/__tests__/AssignSecurityTokenRole.ts @@ -33,6 +33,7 @@ describe('AssignSecurityTokenRole', () => { // Mock the context, wrappers, and tokenFactory to test AssignSecurityTokenRole contextMock = ImportMock.mockClass(contextModule, 'Context'); wrappersMock = ImportMock.mockClass(wrappersModule, 'PolymathBase'); + tokenFactoryMock = ImportMock.mockClass(tokenFactoryModule, 'MockedTokenFactoryModule'); contextMock.set('contractWrappers', wrappersMock.getMockInstance()); diff --git a/src/procedures/__tests__/TransferErc20.ts b/src/procedures/__tests__/TransferErc20.ts new file mode 100644 index 0000000..e2fac01 --- /dev/null +++ b/src/procedures/__tests__/TransferErc20.ts @@ -0,0 +1,223 @@ +import { ImportMock, MockManager } from 'ts-mock-imports'; +import { restore, spy } from 'sinon'; +import * as contractWrappersModule from '@polymathnetwork/contract-wrappers'; +import { BigNumber, TransactionReceiptWithDecodedLogs } from '@polymathnetwork/contract-wrappers'; +import { Procedure } from '../Procedure'; +import { + ErrorCode, + PolyTransactionTag, + ProcedureType, + TransferErc20ProcedureArgs, +} from '../../types'; +import * as erc20TokenBalanceFactoryModule from '../../entities/factories/Erc20TokenBalanceFactory'; +import * as contextModule from '../../Context'; +import * as wrappersModule from '../../PolymathBase'; +import * as tokenFactoryModule from '../../testUtils/MockedTokenFactoryModule'; +import * as moduleWrapperFactoryModule from '../../testUtils/MockedModuleWrapperFactoryModule'; +import { Wallet } from '../../Wallet'; +import { TransferErc20 } from '../../procedures'; +import * as transferErc20Module from '../../procedures/TransferErc20'; +import { mockFactories } from '../../testUtils/mockFactories'; +import { PolymathError } from '../../PolymathError'; +import { Erc20TokenBalance } from '../../entities'; +import { Factories } from '../../Context'; + +const params: TransferErc20ProcedureArgs = { + amount: new BigNumber(10), + receiver: '0x6666666666666666666666666666666666666666', + tokenAddress: '0x7777777777777777777777777777777777777777', +}; +const currentWallet = '0x8888888888888888888888888888888888888888'; +const polyTokenAddress = '0x9999999999999999999999999999999999999999'; + +describe('TransferErc20', () => { + let target: TransferErc20; + let contextMock: MockManager; + let wrappersMock: MockManager; + let tokenFactoryMock: MockManager; + let moduleWrapperFactoryMock: MockManager< + moduleWrapperFactoryModule.MockedModuleWrapperFactoryModule + >; + let polyTokenMock: MockManager; + + // Mock factories + let erc20TokenBalanceFactoryMock: MockManager< + erc20TokenBalanceFactoryModule.Erc20TokenBalanceFactory + >; + + let securityTokenRegistryMock: MockManager; + + let erc20Mock: MockManager; + let factoryMockSetup: Factories; + + beforeEach(() => { + // Mock the context, wrappers, and tokenFactory to test TransferErc20 + contextMock = ImportMock.mockClass(contextModule, 'Context'); + wrappersMock = ImportMock.mockClass(wrappersModule, 'PolymathBase'); + tokenFactoryMock = ImportMock.mockClass(tokenFactoryModule, 'MockedTokenFactoryModule'); + moduleWrapperFactoryMock = ImportMock.mockClass( + moduleWrapperFactoryModule, + 'MockedModuleWrapperFactoryModule' + ); + + contextMock.set('contractWrappers', wrappersMock.getMockInstance()); + wrappersMock.set('tokenFactory', tokenFactoryMock.getMockInstance()); + wrappersMock.set('moduleFactory', moduleWrapperFactoryMock.getMockInstance()); + + erc20Mock = ImportMock.mockClass(contractWrappersModule, 'ERC20'); + erc20Mock.mock('balanceOf', Promise.resolve(new BigNumber(20))); + + erc20Mock.mock('address', Promise.resolve(params.tokenAddress)); + + securityTokenRegistryMock = ImportMock.mockClass( + contractWrappersModule, + 'SecurityTokenRegistry' + ); + securityTokenRegistryMock.mock('isSecurityToken', Promise.resolve(false)); + + wrappersMock.set('securityTokenRegistry', securityTokenRegistryMock.getMockInstance()); + wrappersMock.mock('getERC20TokenWrapper', erc20Mock.getMockInstance()); + + erc20TokenBalanceFactoryMock = ImportMock.mockClass( + erc20TokenBalanceFactoryModule, + 'Erc20TokenBalanceFactory' + ); + + factoryMockSetup = mockFactories(); + factoryMockSetup.erc20TokenBalanceFactory = erc20TokenBalanceFactoryMock.getMockInstance(); + + erc20TokenBalanceFactoryMock.mock('refresh', Promise.resolve()); + contextMock.set('factories', factoryMockSetup); + contextMock.set('currentWallet', new Wallet({ address: () => Promise.resolve(currentWallet) })); + + polyTokenMock = ImportMock.mockClass(contractWrappersModule, 'PolyToken'); + polyTokenMock.mock('address', Promise.resolve(polyTokenAddress)); + wrappersMock.set('polyToken', polyTokenMock.getMockInstance()); + wrappersMock.mock('isTestnet', Promise.resolve(false)); + + // Instantiate TransferErc20 + target = new TransferErc20(params, contextMock.getMockInstance()); + }); + afterEach(() => { + restore(); + }); + + describe('Types', () => { + test('should extend procedure and have TransferErc20 type', async () => { + expect(target instanceof Procedure).toBe(true); + expect(target.type).toBe(ProcedureType.TransferErc20); + }); + }); + + describe('TransferErc20', () => { + test('should add a transaction to the queue to transfer an erc20 token with specified token address to a specified receiving address', async () => { + const addTransactionSpy = spy(target, 'addTransaction'); + erc20Mock.mock('transfer', Promise.resolve('Transfer')); + // Real call + await target.prepareTransactions(); + + // Verifications + expect(addTransactionSpy.getCall(0).calledWith(erc20Mock.getMockInstance().transfer)).toEqual( + true + ); + expect(addTransactionSpy.getCall(0).lastArg.tag).toEqual(PolyTransactionTag.TransferErc20); + expect(addTransactionSpy.callCount).toEqual(1); + }); + + test('should add a transaction to the queue to transfer poly as the parameters do not include token address', async () => { + target = new TransferErc20( + { ...params, tokenAddress: undefined }, + contextMock.getMockInstance() + ); + polyTokenMock.mock('balanceOf', Promise.resolve(new BigNumber(20))); + + const addTransactionSpy = spy(target, 'addTransaction'); + polyTokenMock.mock('transfer', Promise.resolve('Transfer')); + + // Real call + await target.prepareTransactions(); + + // Verifications + expect( + addTransactionSpy.getCall(0).calledWith(polyTokenMock.getMockInstance().transfer) + ).toEqual(true); + expect(addTransactionSpy.getCall(0).lastArg.tag).toEqual(PolyTransactionTag.TransferErc20); + expect(addTransactionSpy.callCount).toEqual(1); + }); + + test('should throw if supplied address does not correspond to a valid erc20 token', async () => { + wrappersMock + .mock('getERC20TokenWrapper') + .withArgs({ address: params.tokenAddress }) + .throws(); + + await expect(target.prepareTransactions()).rejects.toThrow( + new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: 'The supplied address does not correspond to an ERC20 token', + }) + ); + }); + + test('should throw if address belongs to a security token, not an erc20 token', async () => { + securityTokenRegistryMock.mock('isSecurityToken', Promise.resolve(true)); + + await expect(target.prepareTransactions()).rejects.toThrow( + new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: + "This address belongs to a Security Token. To transfer Security Tokens, use the functions in the Security Token's transfers namespace", + }) + ); + }); + + test('should add an extra transaction to get POLY from the faucet if the balance is insufficient, specifically on testnet', async () => { + wrappersMock.mock('isTestnet', Promise.resolve(true)); + erc20Mock.mock('address', Promise.resolve(polyTokenAddress)); + erc20Mock.mock('balanceOf', Promise.resolve(new BigNumber(2))); + erc20Mock.mock('transfer', Promise.resolve('Transfer')); + wrappersMock.mock('getPolyTokens', Promise.resolve('GetPolyTokens')); + const addTransactionSpy = spy(target, 'addTransaction'); + // Real call + await target.prepareTransactions(); + + // Verifications + expect( + addTransactionSpy.getCall(0).calledWith(wrappersMock.getMockInstance().getPolyTokens) + ).toEqual(true); + expect(addTransactionSpy.getCall(0).lastArg.tag).toEqual(PolyTransactionTag.GetTokens); + expect(addTransactionSpy.getCall(1).calledWith(erc20Mock.getMockInstance().transfer)).toEqual( + true + ); + expect(addTransactionSpy.getCall(1).lastArg.tag).toEqual(PolyTransactionTag.TransferErc20); + expect(addTransactionSpy.callCount).toEqual(2); + }); + + test('should throw error if there are not enough funds to make an erc20 transfer', async () => { + erc20Mock.mock('balanceOf', Promise.resolve(new BigNumber(2))); + + await expect(target.prepareTransactions()).rejects.toThrow( + new PolymathError({ + code: ErrorCode.ProcedureValidationError, + message: 'Not enough funds', + }) + ); + }); + + test('should successfully refresh the corresponding ERC20 Balance Entity', async () => { + const refreshStub = erc20TokenBalanceFactoryMock.mock('refresh', Promise.resolve()); + const erc20TokenBalanceGeneratedId = Erc20TokenBalance.generateId({ + tokenAddress: params.tokenAddress!, + walletAddress: params.receiver, + }); + const resolverValue = await transferErc20Module.createTransferErc20Resolver( + factoryMockSetup, + params.tokenAddress!, + params.receiver + )(); + expect(refreshStub.getCall(0).calledWithExactly(erc20TokenBalanceGeneratedId)).toEqual(true); + expect(resolverValue).toEqual(undefined); + expect(refreshStub.callCount).toEqual(1); + }); + }); +});