diff --git a/commander/src/commands/transaction/create.ts b/commander/src/commands/transaction/create.ts index 6027860dbd2..740ff171a39 100644 --- a/commander/src/commands/transaction/create.ts +++ b/commander/src/commands/transaction/create.ts @@ -21,10 +21,9 @@ import { flags as commonFlags } from '../../utils/flags'; import DelegateCommand from './create/delegate'; import MultisignatureCommand from './create/multisignature'; import TransferCommand from './create/transfer'; +import UnlockCommand from './create/unlock'; import VoteCommand from './create/vote'; -const MAX_ARG_NUM = 5; - interface TypeNumberMap { readonly [key: string]: string; } @@ -34,6 +33,7 @@ const typeNumberMap: TypeNumberMap = { '10': 'delegate', '11': 'vote', '12': 'multisignature', + '14': 'unlock', }; const options = Object.entries(typeNumberMap).reduce( @@ -54,6 +54,7 @@ const typeClassMap: TypeClassMap = { vote: VoteCommand, delegate: DelegateCommand, multisignature: MultisignatureCommand, + unlock: UnlockCommand, }; const resolveFlags = ( @@ -72,9 +73,7 @@ const resolveFlags = ( }; export default class CreateCommand extends BaseCommand { - static args = new Array(MAX_ARG_NUM).fill(0).map(i => ({ - name: `${i}_arg`, - })); + static strict = false; static description = ` Creates a transaction object. diff --git a/commander/src/commands/transaction/create/unlock.ts b/commander/src/commands/transaction/create/unlock.ts new file mode 100644 index 00000000000..f34dbef5b2b --- /dev/null +++ b/commander/src/commands/transaction/create/unlock.ts @@ -0,0 +1,193 @@ +/* + * LiskHQ/lisk-commander + * Copyright © 2020 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + * + */ +import { + unlockToken, + utils as transactionUtils, +} from '@liskhq/lisk-transactions'; +import { + isNumberString, + isValidFee, + isValidNonce, + validateAddress, +} from '@liskhq/lisk-validator'; +import { flags as flagParser } from '@oclif/command'; + +import BaseCommand from '../../../base'; +import { ValidationError } from '../../../utils/error'; +import { flags as commonFlags } from '../../../utils/flags'; +import { getNetworkIdentifierWithInput } from '../../../utils/network_identifier'; +import { getPassphraseFromPrompt } from '../../../utils/reader'; + +interface RawAssetUnlock { + readonly delegateAddress: string; + readonly amount: string; + readonly unvoteHeight: number; +} + +const createUnlockTransaction = ( + nonce: string, + fee: string, + networkIdentifier: string, + unlockingObjects: ReadonlyArray, + passphrase?: string, +) => + unlockToken({ + nonce, + fee, + networkIdentifier, + passphrase, + unlockingObjects, + }); + +interface Args { + readonly nonce: string; + readonly fee: string; +} + +const splitInputs = (unlock: string) => + unlock + .split(',') + .filter(Boolean) + .map(u => u.trim()); + +const validateUnlocks = ( + unlocks: ReadonlyArray, +): ReadonlyArray => { + const rawAssetUnlock = []; + for (const unlock of unlocks) { + const [delegateAddress, amount, unvoteHeight] = splitInputs(unlock); + if (!validateAddress(delegateAddress)) { + throw new ValidationError('Enter a valid address in LSK string format.'); + } + const normalizedAmount = transactionUtils.convertLSKToBeddows(amount); + + if (!isValidFee(normalizedAmount)) { + throw new ValidationError( + 'Enter a valid amount in number string format.', + ); + } + + if (!isNumberString(unvoteHeight)) { + throw new ValidationError( + 'Enter the unvoteHeight in valid number format.', + ); + } + + rawAssetUnlock.push({ + delegateAddress, + amount: normalizedAmount, + unvoteHeight: parseInt(unvoteHeight, 10), + }); + } + + return rawAssetUnlock; +}; + +export default class UnlockCommand extends BaseCommand { + static args = [ + { + name: 'nonce', + required: true, + description: 'Nonce of the transaction.', + }, + { + name: 'fee', + required: true, + description: 'Transaction fee in LSK.', + }, + ]; + static description = ` + Creates a transaction which will unlock tokens voted for delegates and add them back to the sender balance. + `; + + static examples = [ + 'transaction:create:unlock 1 100 --unlock="123L,1000000000,500"', + 'transaction:create:unlock 1 100 --unlock="123L,1000000000,500" --unlock="456L,1000000000,500"', + ]; + + static flags = { + ...BaseCommand.flags, + unlock: flagParser.string({ ...commonFlags.unlock, multiple: true }), + 'no-signature': flagParser.boolean(commonFlags.noSignature), + networkIdentifier: flagParser.string(commonFlags.networkIdentifier), + passphrase: flagParser.string(commonFlags.passphrase), + }; + + async run(): Promise { + const { + args, + flags: { + unlock: unlocks, + networkIdentifier: networkIdentifierSource, + passphrase: passphraseSource, + 'no-signature': noSignature, + }, + } = this.parse(UnlockCommand); + + const { nonce, fee } = args as Args; + + if (!isValidNonce(nonce)) { + throw new ValidationError('Enter a valid nonce in number string format.'); + } + + if (Number.isNaN(Number(fee))) { + throw new ValidationError('Enter a valid fee in number string format.'); + } + + const normalizedFee = transactionUtils.convertLSKToBeddows(fee); + + if (!isValidFee(normalizedFee)) { + throw new ValidationError('Enter a valid fee in number string format.'); + } + + if (!unlocks?.length) { + throw new ValidationError( + 'At least one unlock object options must be provided.', + ); + } + + const unlockingObjects = validateUnlocks(unlocks); + + const networkIdentifier = getNetworkIdentifierWithInput( + networkIdentifierSource, + this.userConfig.api.network, + ); + + if (noSignature) { + const noSignatureResult = createUnlockTransaction( + nonce, + normalizedFee, + networkIdentifier, + unlockingObjects, + ); + this.print(noSignatureResult); + + return; + } + + const passphrase = + passphraseSource ?? (await getPassphraseFromPrompt('passphrase', true)); + + const result = createUnlockTransaction( + nonce, + normalizedFee, + networkIdentifier, + unlockingObjects, + passphrase, + ); + this.print(result); + } +} diff --git a/commander/src/utils/flags.ts b/commander/src/utils/flags.ts index f8e8aff37d2..e626374b45c 100644 --- a/commander/src/utils/flags.ts +++ b/commander/src/utils/flags.ts @@ -36,6 +36,11 @@ const votesDescription = `Specifies the public keys for the delegate candidates - --votes=publickey1,publickey2 `; +const unlockDescription = `Specifies the unlock objects for the delegate candidates to unlock from. Takes a string of address amount unvoteHeight separated by commas. + Examples: + - --unlock=123L,1000000,500 +`; + const unvotesDescription = `Specifies the public keys for the delegate candidates you want to remove your vote from. Takes a string of public keys separated by commas. Examples: - --unvotes=publickey1,publickey2 @@ -121,6 +126,9 @@ export const flags: FlagMap = { votes: { description: votesDescription, }, + unlock: { + description: unlockDescription, + }, networkIdentifier: { description: networkIdentifierDescription, }, diff --git a/commander/test/commands/transaction/create.test.ts b/commander/test/commands/transaction/create.test.ts index d9efbc6a242..2d251f74684 100644 --- a/commander/test/commands/transaction/create.test.ts +++ b/commander/test/commands/transaction/create.test.ts @@ -21,6 +21,7 @@ import TransferCommand from '../../../src/commands/transaction/create/transfer'; import MultisignatureCommand from '../../../src/commands/transaction/create/multisignature'; import DelegateCommand from '../../../src/commands/transaction/create/delegate'; import VoteCommand from '../../../src/commands/transaction/create/vote'; +import UnlockCommand from '../../../src/commands/transaction/create/unlock'; describe('transaction:create', () => { const printMethodStub = sandbox.stub(); @@ -35,7 +36,8 @@ describe('transaction:create', () => { .stub(TransferCommand, 'run', sandbox.stub()) .stub(DelegateCommand, 'run', sandbox.stub()) .stub(VoteCommand, 'run', sandbox.stub()) - .stub(MultisignatureCommand, 'run', sandbox.stub()); + .stub(MultisignatureCommand, 'run', sandbox.stub()) + .stub(UnlockCommand, 'run', sandbox.stub()); describe('transaction:create', () => { setupTest() @@ -58,19 +60,19 @@ describe('transaction:create', () => { setupTest() .command(['transaction:create', '--type=8']) - .it('should call type 0 command with flag type=9', () => { + .it('should call type 8 command with flag type=8', () => { return expect(TransferCommand.run).to.be.calledWithExactly([]); }); setupTest() .command(['transaction:create', '--type=transfer']) - .it('should call type 0 command with flag type=transfer', () => { + .it('should call type 8 transfer with flag type=transfer', () => { return expect(TransferCommand.run).to.be.calledWithExactly([]); }); setupTest() .command(['transaction:create', '--type=10', 'username']) - .it('should call type 2 command with flag type=10', () => { + .it('should call type 10 command with flag type=10', () => { return expect(DelegateCommand.run).to.be.calledWithExactly([ 'username', ]); @@ -78,7 +80,7 @@ describe('transaction:create', () => { setupTest() .command(['transaction:create', '-t=delegate', '--json', 'username']) - .it('should call type 2 command with flag type=delegate', () => { + .it('should call type 10 command with flag type=delegate', () => { return expect(DelegateCommand.run).to.be.calledWithExactly([ 'username', '--json', @@ -87,7 +89,7 @@ describe('transaction:create', () => { setupTest() .command(['transaction:create', '--type=11', '--votes=xxx,yyy']) - .it('should call type 3 command with flag type=11', () => { + .it('should call type 11 command with flag type=11', () => { return expect(VoteCommand.run).to.be.calledWithExactly([ '--votes', 'xxx,yyy', @@ -96,7 +98,7 @@ describe('transaction:create', () => { setupTest() .command(['transaction:create', '-t=vote', '--votes=xxx,xxx']) - .it('should call type 3 command with flag type=vote', () => { + .it('should call type 11 command with flag type=vote', () => { return expect(VoteCommand.run).to.be.calledWithExactly([ '--votes', 'xxx,xxx', @@ -130,5 +132,43 @@ describe('transaction:create', () => { '--optional-key=yyy', ]); }); + + setupTest() + .command(['transaction:create', '--type=14', '--unlock=xxx,yyy,zzz']) + .it('should call type 14 command with flag type=14', () => { + return expect(UnlockCommand.run).to.be.calledWithExactly([ + '--unlock=xxx,yyy,zzz', + ]); + }); + + setupTest() + .command(['transaction:create', '--type=unlock', '--unlock=xxx,yyy,zzz']) + .it('should call type 14 command with flag type=unlock', () => { + return expect(UnlockCommand.run).to.be.calledWithExactly([ + '--unlock=xxx,yyy,zzz', + ]); + }); + + setupTest() + .command([ + 'transaction:create', + '--type=unlock', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + ]) + .it('should allow to use more flags and arguments', () => { + return expect(UnlockCommand.run).to.be.calledWithExactly([ + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + '--unlock=xxx,yyy,zzz', + ]); + }); }); }); diff --git a/commander/test/commands/transaction/create/unlock.test.ts b/commander/test/commands/transaction/create/unlock.test.ts new file mode 100644 index 00000000000..76dd0b00a53 --- /dev/null +++ b/commander/test/commands/transaction/create/unlock.test.ts @@ -0,0 +1,233 @@ +/* + * LiskHQ/lisk-commander + * Copyright © 2020 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + * + */ +import * as sandbox from 'sinon'; +import { expect, test } from '@oclif/test'; +import * as transactions from '@liskhq/lisk-transactions'; +import * as config from '../../../../src/utils/config'; +import * as printUtils from '../../../../src/utils/print'; +import * as readerUtils from '../../../../src/utils/reader'; + +describe('transaction:create:unlock', () => { + const defaultSenderPublicKey = + 'a4465fd76c16fcc458448076372abf1912cc5b150663a64dffefe550f96feadd'; + const networkIdentifier = + 'e48feb88db5b5cf5ad71d93cdcd1d879b6d5ed187a36b0002cc34e0ef9883255'; + const defaultPassphrase = '123'; + const defaultNonce = 1; + const defaultFee = 1; + const defaultDelegateAddress = '123L'; + + const defaultUnlockTransaction = { + nonce: defaultNonce, + fee: defaultFee, + passphrase: defaultPassphrase, + networkIdentifier, + senderPublicKey: defaultSenderPublicKey, + type: transactions.UnlockTransaction.TYPE, + asset: { + unlockingObjects: [ + { + delegateAddress: defaultDelegateAddress, + amount: '100000000000000000', + unvoteHeight: 500, + }, + ], + }, + }; + + const printMethodStub = sandbox.stub(); + + const setupStub = () => + test + .stub(printUtils, 'print', sandbox.stub().returns(printMethodStub)) + .stub( + config, + 'getConfig', + sandbox.stub().returns({ api: { network: 'test' } }), + ) + .stub( + transactions, + 'unlockToken', + sandbox.stub().returns(defaultUnlockTransaction), + ) + .stub( + readerUtils, + 'getPassphraseFromPrompt', + sandbox.stub().resolves(defaultPassphrase), + ) + .stdout(); + + describe('transaction:create:unlock', () => { + setupStub() + .command(['transaction:create:unlock', '1', '100000000']) + .catch(error => { + return expect(error.message).to.contain( + 'At least one unlock object options must be provided.', + ); + }) + .it('should throw an error without unlock flag'); + }); + + describe('transaction:create:unlock --unlock="x,y,z"', () => { + setupStub() + .command([ + 'transaction:create:unlock', + '1', + '100000000', + '--unlock=123,1000000,100', + ]) + .catch(error => { + return expect(error.message).to.contain( + 'Address format does not match requirements. Expected "L" at the end.', + ); + }) + .it('should throw an error for invalid address format in unlock object'); + }); + + describe('transaction:create:unlock --unlock="x,y,z"', () => { + setupStub() + .command([ + 'transaction:create:unlock', + '1', + '1', + `--unlock=${defaultDelegateAddress},1000000000,500`, + ]) + .it('should create a unlock transaction', () => { + expect(readerUtils.getPassphraseFromPrompt).to.be.calledWithExactly( + 'passphrase', + true, + ); + expect(transactions.unlockToken).to.be.calledWithExactly({ + nonce: defaultNonce.toString(), + fee: '100000000', + networkIdentifier, + passphrase: defaultPassphrase, + unlockingObjects: [ + { + delegateAddress: defaultDelegateAddress, + amount: '100000000000000000', + unvoteHeight: 500, + }, + ], + }); + return expect(printMethodStub).to.be.calledWithExactly( + defaultUnlockTransaction, + ); + }); + }); + + describe('transaction:create:unlock --unlock="x,y,z"', () => { + setupStub() + .command([ + 'transaction:create:unlock', + '1', + '1', + `--unlock=${defaultDelegateAddress},1000000000,500`, + '--unlock=456L,1000000000,500', + ]) + .it( + 'should create a unlock transaction with multiple unlock objects', + () => { + expect(readerUtils.getPassphraseFromPrompt).to.be.calledWithExactly( + 'passphrase', + true, + ); + + return expect(transactions.unlockToken).to.be.calledWithExactly({ + nonce: defaultNonce.toString(), + fee: '100000000', + networkIdentifier, + passphrase: defaultPassphrase, + unlockingObjects: [ + { + delegateAddress: defaultDelegateAddress, + amount: '100000000000000000', + unvoteHeight: 500, + }, + { + delegateAddress: '456L', + amount: '100000000000000000', + unvoteHeight: 500, + }, + ], + }); + }, + ); + }); + + describe('transaction:create:unlock --unlock="x,y,z" --no-signature', () => { + setupStub() + .command([ + 'transaction:create:unlock', + '1', + '1', + `--unlock=${defaultDelegateAddress},1000000000,500`, + '--no-signature', + ]) + .it('should create a unlock transaction without signature', () => { + expect(readerUtils.getPassphraseFromPrompt).not.to.be.called; + expect(transactions.unlockToken).to.be.calledWithExactly({ + nonce: defaultNonce.toString(), + fee: '100000000', + networkIdentifier, + passphrase: undefined, + unlockingObjects: [ + { + delegateAddress: defaultDelegateAddress, + amount: '100000000000000000', + unvoteHeight: 500, + }, + ], + }); + return expect(printMethodStub).to.be.calledWithExactly( + defaultUnlockTransaction, + ); + }); + }); + + describe('transaction:create:unlock --unlock="x,y,z" --passphrase=123', () => { + setupStub() + .command([ + 'transaction:create:unlock', + '1', + '1', + `--unlock=${defaultDelegateAddress},1000000000,500`, + '--passphrase=123', + ]) + .it( + 'should create a unlock transaction with the passphrase from the flag', + () => { + expect(readerUtils.getPassphraseFromPrompt).not.to.be.called; + expect(transactions.unlockToken).to.be.calledWithExactly({ + nonce: defaultNonce.toString(), + fee: '100000000', + networkIdentifier, + passphrase: defaultPassphrase, + unlockingObjects: [ + { + delegateAddress: defaultDelegateAddress, + amount: '100000000000000000', + unvoteHeight: 500, + }, + ], + }); + return expect(printMethodStub).to.be.calledWithExactly( + defaultUnlockTransaction, + ); + }, + ); + }); +});