From fe568b5fda3d3e27cf2ced01581e52ede08ee58d Mon Sep 17 00:00:00 2001 From: Sakshi Joshi Date: Wed, 19 Feb 2025 19:00:08 +0530 Subject: [PATCH] feat(sdk-coin-tao): add unstaking builder for TAO Ticket: SC-1059 --- .../src/lib/transactionBuilderFactory.ts | 7 + .../sdk-coin-tao/src/lib/unstakeBuilder.ts | 152 ++++++++++++++++++ modules/sdk-coin-tao/test/resources/index.ts | 2 +- .../unit/transactionBuilder/unstakeBuilder.ts | 119 ++++++++++++++ 4 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-tao/src/lib/unstakeBuilder.ts create mode 100644 modules/sdk-coin-tao/test/unit/transactionBuilder/unstakeBuilder.ts diff --git a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts index ba2e3b937f..818fa8e308 100644 --- a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts @@ -5,6 +5,7 @@ import { SingletonRegistry, TransactionBuilder, Interface } from './'; import { TransferBuilder } from './transferBuilder'; import utils from './utils'; import { StakingBuilder } from './stakingBuilder'; +import { UnstakeBuilder } from './unstakeBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { protected _material: Interface.Material; @@ -22,6 +23,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new StakingBuilder(this._coinConfig).material(this._material); } + getUnstakingBuilder(): UnstakeBuilder { + return new UnstakeBuilder(this._coinConfig).material(this._material); + } + getWalletInitializationBuilder(): void { throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`); } @@ -49,6 +54,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getTransferBuilder(); } else if (methodName === Interface.MethodNames.AddStake) { return this.getStakingBuilder(); + } else if (methodName === Interface.MethodNames.RemoveStake) { + return this.getUnstakingBuilder(); } else { throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); } diff --git a/modules/sdk-coin-tao/src/lib/unstakeBuilder.ts b/modules/sdk-coin-tao/src/lib/unstakeBuilder.ts new file mode 100644 index 0000000000..f2292370bb --- /dev/null +++ b/modules/sdk-coin-tao/src/lib/unstakeBuilder.ts @@ -0,0 +1,152 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { defineMethod, UnsignedTransaction, DecodedSignedTx, DecodedSigningPayload } from '@substrate/txwrapper-core'; +import BigNumber from 'bignumber.js'; +import { InvalidTransactionError, TransactionType, BaseAddress } from '@bitgo/sdk-core'; +import { Transaction, TransactionBuilder, Interface, Schema } from '@bitgo/abstract-substrate'; + +export class UnstakeBuilder extends TransactionBuilder { + protected _amount: number; + protected _hotkey: string; + protected _netuid: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** + * Construct a transaction to unstake + * + * @returns {UnsignedTransaction} an unsigned unstake TAO transaction + * + * @see https://polkadot.js.org/docs/substrate/extrinsics/#staking + */ + protected buildTransaction(): UnsignedTransaction { + const baseTxInfo = this.createBaseTxInfo(); + return this.removeStake( + { + amountUnstaked: this._amount, + hotkey: this._hotkey, + netuid: this._netuid, + }, + baseTxInfo + ); + } + + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.StakingDeactivate; + } + + /** + * The amount to unstake. + * + * @param {number} amount to unstake + * @returns {UnstakeBuilder} This unstaking builder. + * + * @see https://wiki.polkadot.network/docs/learn-nominator#required-minimum-stake + */ + amount(amount: number): this { + this.validateValue(new BigNumber(amount)); + this._amount = amount; + return this; + } + + /** + * The controller of the staked amount. + * + * @param {string} hotkey address of validator + * @returns {UnstakeBuilder} This unstaking builder. + * + * @see https://wiki.polkadot.network/docs/learn-staking#accounts + */ + hotkey({ address }: BaseAddress): this { + this.validateAddress({ address }); + this._hotkey = address; + return this; + } + + /** + * Netuid of the subnet (root network is 0) + * @param {number} netuid + * @returns {UnstakeBuilder} This unstaking builder + */ + netuid(netuid: number): this { + this._netuid = netuid; + return this; + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + const tx = super.fromImplementation(rawTransaction); + if (this._method?.name === Interface.MethodNames.RemoveStake) { + const txMethod = this._method.args as Interface.RemoveStakeArgs; + this.amount(txMethod.amountUnstaked); + this.hotkey({ address: txMethod.hotkey }); + this.netuid(txMethod.netuid); + } else { + throw new InvalidTransactionError(`Invalid Transaction Type: ${this._method?.name}. Expected addStake`); + } + return tx; + } + + /** @inheritdoc */ + validateTransaction(_: Transaction): void { + super.validateTransaction(_); + this.validateFields(this._amount, this._hotkey, this._netuid); + } + + /** + * Helper method to validate whether unstake params have the correct type and format + * @param {number} amountUnstaked amount to unstake + * @param {string} hotkey hotkey address of the validator + * @param {number} netuid netuid of the subnet + */ + private validateFields(amountUnstaked: number, hotkey: string, netuid: number): void { + const validationResult = Schema.UnstakeTransactionSchema.validate({ + amountUnstaked, + hotkey, + netuid, + }); + + if (validationResult.error) { + throw new InvalidTransactionError( + `UnStake Builder Transaction validation failed: ${validationResult.error.message}` + ); + } + } + + /** @inheritdoc */ + validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx, rawTransaction: string): void { + if (decodedTxn.method?.name === Interface.MethodNames.RemoveStake) { + const txMethod = decodedTxn.method.args as unknown as Interface.RemoveStakeArgs; + const amountUnstaked = txMethod.amountUnstaked; + const hotkey = txMethod.hotkey; + const netuid = txMethod.netuid; + const validationResult = Schema.UnstakeTransactionSchema.validate({ amountUnstaked, hotkey, netuid }); + if (validationResult.error) { + throw new InvalidTransactionError(`Transfer Transaction validation failed: ${validationResult.error.message}`); + } + } + } + + /** + * Construct a transaction to unstake + * + * @param {Interface.RemoveStakeArgs} RemoveStake arguments to be passed to the addStake method + * @param {Interface.CreateBaseTxInfo} Base txn info required to construct the removeStake txn + * @returns {UnsignedTransaction} an unsigned unstake TAO transaction + */ + private removeStake(args: Interface.RemoveStakeArgs, info: Interface.CreateBaseTxInfo): UnsignedTransaction { + return defineMethod( + { + method: { + args, + name: 'removeStake', + pallet: 'subtensorModule', + }, + ...info.baseTxInfo, + }, + info.options + ); + } +} diff --git a/modules/sdk-coin-tao/test/resources/index.ts b/modules/sdk-coin-tao/test/resources/index.ts index 2f50cbe635..d92322f24b 100644 --- a/modules/sdk-coin-tao/test/resources/index.ts +++ b/modules/sdk-coin-tao/test/resources/index.ts @@ -142,7 +142,7 @@ export const rawTx = { }, unstake: { signed: - '0xc501840061b18c6dc02ddcabdeac56cb4f21a971cc41cc97640f6f85b073480008c53a0d008cdb9178570210988436b9e274cd40c2e4dd5a7a36c4b4ca3026d331b27ce4d6f01c4757473559d3af5edbc4aa4d78abfbe4e42b36828fdeb8e6780353b2b009d50121030006020b00203d88792d', + '0x55028400aaa34f9f3c1f685e2bac444a4e2d50d302a16f0550f732dd799f854dda7ec77201a4e5222aea1ea19ae4b8f7a542c891e5d8372d9aaafabc4616ecc89fac429a5793b29f79b709c46b41a88634ab2002e69d7777fd095fe014ddad858900155f897401a505000007038a90be061598f4b592afbd546bcb6beadb3c02f5c129df2e11b698f9543dbd41000000e1f50500000000', unsigned: '0x2406020b00203d88792dd501210300be23000008000000e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d', batchAll: { diff --git a/modules/sdk-coin-tao/test/unit/transactionBuilder/unstakeBuilder.ts b/modules/sdk-coin-tao/test/unit/transactionBuilder/unstakeBuilder.ts new file mode 100644 index 0000000000..1c1dcd5b73 --- /dev/null +++ b/modules/sdk-coin-tao/test/unit/transactionBuilder/unstakeBuilder.ts @@ -0,0 +1,119 @@ +import assert from 'assert'; +import should from 'should'; +import { spy, assert as SinonAssert } from 'sinon'; +import { UnstakeBuilder } from '../../../src/lib/unstakeBuilder'; +import { accounts, mockTssSignature, genesisHash, chainName, rawTx } from '../../resources'; +import { buildTestConfig } from './base'; +import utils from '../../../src/lib/utils'; +import { testnetMaterial } from '../../../src/resources'; + +describe('Tao Unstake Builder', function () { + const referenceBlock = '0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d'; + let builder: UnstakeBuilder; + const sender = accounts.account1; + + beforeEach(function () { + const config = buildTestConfig(); + const material = utils.getMaterial(config.network.type); + builder = new UnstakeBuilder(config).material(material); + }); + + describe('setter validation', function () { + it('should validate stake amount', function () { + const spyValidateValue = spy(builder, 'validateValue'); + assert.throws( + () => builder.amount(-1), + (e: Error) => e.message === 'Value cannot be less than zero' + ); + should.doesNotThrow(() => builder.amount(1000)); + SinonAssert.calledTwice(spyValidateValue); + }); + it('should validate hotkey address', function () { + const spyValidateAddress = spy(builder, 'validateAddress'); + assert.throws( + () => builder.hotkey({ address: 'abc' }), + (e: Error) => e.message === `The address 'abc' is not a well-formed dot address` + ); + should.doesNotThrow(() => builder.hotkey({ address: '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT' })); + SinonAssert.calledTwice(spyValidateAddress); + }); + }); + + describe('build unstake transaction', function () { + it('should build a unstake transaction', async function () { + builder + .amount(50000000000000) + .hotkey({ address: '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT' }) + .netuid(0) + .sender({ address: sender.address }) + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 200 }) + .fee({ amount: 0, type: 'tip' }) + .addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + + const tx = await builder.build(); + const txJson = tx.toJson(); + should.deepEqual(txJson.amount, '50000000000000'); + should.deepEqual(txJson.to, '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + should.deepEqual(txJson.netuid, '0'); + should.deepEqual(txJson.sender, sender.address); + should.deepEqual(txJson.blockNumber, 3933); + should.deepEqual(txJson.referenceBlock, referenceBlock); + should.deepEqual(txJson.genesisHash, genesisHash); + should.deepEqual(txJson.specVersion, Number(testnetMaterial.specVersion)); + should.deepEqual(txJson.nonce, 200); + should.deepEqual(txJson.tip, 0); + should.deepEqual(txJson.transactionVersion, Number(testnetMaterial.txVersion)); + should.deepEqual(txJson.chainName.toLowerCase(), chainName); + should.deepEqual(txJson.eraPeriod, 64); + }); + + it('should build an unsigned unstake transaction', async function () { + builder + .amount(50000000000000) + .hotkey({ address: '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT' }) + .netuid(0) + .sender({ address: sender.address }) + .validity({ firstValid: 3933, maxDuration: 64 }) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 200 }) + .fee({ amount: 0, type: 'tip' }); + const tx = await builder.build(); + const txJson = tx.toJson(); + should.deepEqual(txJson.amount, '50000000000000'); + should.deepEqual(txJson.to, '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + should.deepEqual(txJson.netuid, '0'); + should.deepEqual(txJson.sender, sender.address); + should.deepEqual(txJson.blockNumber, 3933); + should.deepEqual(txJson.referenceBlock, referenceBlock); + should.deepEqual(txJson.genesisHash, genesisHash); + should.deepEqual(txJson.specVersion, Number(testnetMaterial.specVersion)); + should.deepEqual(txJson.nonce, 200); + should.deepEqual(txJson.tip, 0); + should.deepEqual(txJson.transactionVersion, Number(testnetMaterial.txVersion)); + should.deepEqual(txJson.chainName.toLowerCase(), chainName); + should.deepEqual(txJson.eraPeriod, 64); + }); + + it('should build from raw signed tx', async function () { + builder.from(rawTx.unstake.signed); + builder.validity({ firstValid: 3933, maxDuration: 64 }).referenceBlock(referenceBlock); + const tx = await builder.build(); + const txJson = tx.toJson(); + should.deepEqual(txJson.amount, '100000000'); + should.deepEqual(txJson.to, '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + should.deepEqual(txJson.netuid, '0'); + should.deepEqual(txJson.sender, '5FvSWbV4hGC7GvXQKKtiVmmHSH3JELK8R3JS8Z5adnACFBwh'); + should.deepEqual(txJson.blockNumber, 3933); + should.deepEqual(txJson.referenceBlock, referenceBlock); + should.deepEqual(txJson.genesisHash, genesisHash); + should.deepEqual(txJson.specVersion, Number(testnetMaterial.specVersion)); + should.deepEqual(txJson.nonce, 361); + should.deepEqual(txJson.tip, 0); + should.deepEqual(txJson.transactionVersion, Number(testnetMaterial.txVersion)); + should.deepEqual(txJson.chainName.toLowerCase(), chainName); + should.deepEqual(txJson.eraPeriod, 64); + }); + }); +});