From 03a7b368414b61ccfc45f0df5e56378fcd32168c Mon Sep 17 00:00:00 2001 From: raksha-r7 Date: Tue, 9 Sep 2025 20:00:56 +0530 Subject: [PATCH] feat(sdk-coin-tao): add moveStakeBuilder Ticket: SC-3017 --- modules/abstract-substrate/src/lib/iface.ts | 15 +- .../abstract-substrate/src/lib/txnSchema.ts | 8 + modules/abstract-substrate/src/lib/utils.ts | 11 + modules/sdk-coin-tao/src/lib/constants.ts | 10 + modules/sdk-coin-tao/src/lib/iface.ts | 8 + modules/sdk-coin-tao/src/lib/index.ts | 2 + .../sdk-coin-tao/src/lib/moveStakeBuilder.ts | 225 ++++++++ .../src/lib/moveStakeTransaction.ts | 80 +++ .../src/lib/transactionBuilderFactory.ts | 7 + .../transactionBuilder/moveStakeBuilder.ts | 499 ++++++++++++++++++ 10 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-tao/src/lib/constants.ts create mode 100644 modules/sdk-coin-tao/src/lib/moveStakeBuilder.ts create mode 100644 modules/sdk-coin-tao/src/lib/moveStakeTransaction.ts create mode 100644 modules/sdk-coin-tao/test/unit/transactionBuilder/moveStakeBuilder.ts diff --git a/modules/abstract-substrate/src/lib/iface.ts b/modules/abstract-substrate/src/lib/iface.ts index da7b86257c..5c920469c6 100644 --- a/modules/abstract-substrate/src/lib/iface.ts +++ b/modules/abstract-substrate/src/lib/iface.ts @@ -79,6 +79,10 @@ export const MethodNames = { * Transfer stake from one validator to another. */ TransferStake: 'transferStake' as const, + /** + * Move stake from one hotkey to another. + */ + MoveStake: 'moveStake' as const, } as const; /** @@ -195,6 +199,14 @@ export interface TransferStakeArgs extends Args { alphaAmount: string; } +export interface MoveStakeArgs extends Args { + originHotkey: string; + destinationHotkey: string; + originNetuid: string; + destinationNetuid: string; + alphaAmount: string; +} + /** * Decoded TxMethod from a transaction hex */ @@ -211,7 +223,8 @@ export interface TxMethod { | UnbondArgs | WithdrawUnbondedArgs | BatchArgs - | TransferStakeArgs; + | TransferStakeArgs + | MoveStakeArgs; name: MethodNamesValues; pallet: string; } diff --git a/modules/abstract-substrate/src/lib/txnSchema.ts b/modules/abstract-substrate/src/lib/txnSchema.ts index b023d83c1f..3628c2a4f0 100644 --- a/modules/abstract-substrate/src/lib/txnSchema.ts +++ b/modules/abstract-substrate/src/lib/txnSchema.ts @@ -65,3 +65,11 @@ export const TransferStakeTransactionSchema = joi.object({ destinationNetuid: joi.string().required(), alphaAmount: joi.string().required(), }); + +export const MoveStakeTransactionSchema = joi.object({ + originHotkey: addressSchema.required(), + destinationHotkey: addressSchema.required(), + originNetuid: joi.string().required(), + destinationNetuid: joi.string().required(), + alphaAmount: joi.string().required(), +}); diff --git a/modules/abstract-substrate/src/lib/utils.ts b/modules/abstract-substrate/src/lib/utils.ts index 89b689e82a..b4c581b98a 100644 --- a/modules/abstract-substrate/src/lib/utils.ts +++ b/modules/abstract-substrate/src/lib/utils.ts @@ -28,6 +28,7 @@ import { UnbondArgs, WithdrawUnbondedArgs, BatchArgs, + MoveStakeArgs, } from './iface'; export class Utils implements BaseUtils { @@ -263,6 +264,16 @@ export class Utils implements BaseUtils { return (arg as BatchArgs).calls !== undefined && Array.isArray((arg as BatchArgs).calls); } + isMoveStake(arg: TxMethod['args']): arg is MoveStakeArgs { + return ( + (arg as MoveStakeArgs).originHotkey !== undefined && + (arg as MoveStakeArgs).destinationHotkey !== undefined && + (arg as MoveStakeArgs).originNetuid !== undefined && + (arg as MoveStakeArgs).destinationNetuid !== undefined && + (arg as MoveStakeArgs).alphaAmount !== undefined + ); + } + /** * extracts and returns the signature in hex format given a raw signed transaction * diff --git a/modules/sdk-coin-tao/src/lib/constants.ts b/modules/sdk-coin-tao/src/lib/constants.ts new file mode 100644 index 0000000000..d8f85e0515 --- /dev/null +++ b/modules/sdk-coin-tao/src/lib/constants.ts @@ -0,0 +1,10 @@ +/** + * Constants for TAO network operations + */ +export const TAO_CONSTANTS = { + /** + * Maximum supported netuid value for TAO subnets + * Valid range is 0 to MAX_NETUID (inclusive) + */ + MAX_NETUID: 128, +} as const; diff --git a/modules/sdk-coin-tao/src/lib/iface.ts b/modules/sdk-coin-tao/src/lib/iface.ts index fd5ff7c6fc..f4ec765171 100644 --- a/modules/sdk-coin-tao/src/lib/iface.ts +++ b/modules/sdk-coin-tao/src/lib/iface.ts @@ -7,3 +7,11 @@ export interface TransferStakeTxData extends Interface.TxData { destinationNetuid: string; alphaAmount: string; } + +export interface MoveStakeTxData extends Interface.TxData { + originHotkey: string; + destinationHotkey: string; + originNetuid: string; + destinationNetuid: string; + alphaAmount: string; +} diff --git a/modules/sdk-coin-tao/src/lib/index.ts b/modules/sdk-coin-tao/src/lib/index.ts index 544daf9108..f45156241e 100644 --- a/modules/sdk-coin-tao/src/lib/index.ts +++ b/modules/sdk-coin-tao/src/lib/index.ts @@ -14,4 +14,6 @@ export { TokenTransferTransaction } from './tokenTransferTransaction'; export { TransferBuilder } from './transferBuilder'; export { StakingBuilder } from './stakingBuilder'; export { UnstakeBuilder } from './unstakeBuilder'; +export { MoveStakeBuilder } from './moveStakeBuilder'; +export { MoveStakeTransaction } from './moveStakeTransaction'; export { Utils, default as utils } from './utils'; diff --git a/modules/sdk-coin-tao/src/lib/moveStakeBuilder.ts b/modules/sdk-coin-tao/src/lib/moveStakeBuilder.ts new file mode 100644 index 0000000000..03c0cede32 --- /dev/null +++ b/modules/sdk-coin-tao/src/lib/moveStakeBuilder.ts @@ -0,0 +1,225 @@ +import { Interface, Schema, Transaction, TransactionBuilder } from '@bitgo/abstract-substrate'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { DecodedSignedTx, DecodedSigningPayload, defineMethod, UnsignedTransaction } from '@substrate/txwrapper-core'; +import BigNumber from 'bignumber.js'; +import { TAO_CONSTANTS } from './constants'; +import { MoveStakeTransaction } from './moveStakeTransaction'; + +export class MoveStakeBuilder extends TransactionBuilder { + protected _originHotkey: string; + protected _destinationHotkey: string; + protected _originNetuid: string; + protected _destinationNetuid: string; + protected _alphaAmount: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new MoveStakeTransaction(_coinConfig); + } + + /** + * Construct a transaction to move stake + * @returns {UnsignedTransaction} an unsigned move stake transaction + */ + protected buildTransaction(): UnsignedTransaction { + const baseTxInfo = this.createBaseTxInfo(); + return this.moveStake( + { + originHotkey: this._originHotkey, + destinationHotkey: this._destinationHotkey, + originNetuid: this._originNetuid, + destinationNetuid: this._destinationNetuid, + alphaAmount: this._alphaAmount, + }, + baseTxInfo + ); + } + + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.StakingRedelegate; + } + + /** + * Set the amount to move + * @param {string} amount to move + * @returns {MoveStakeBuilder} This builder. + */ + amount(amount: string): this { + const value = new BigNumber(amount); + this.validateAmount(value); + this._alphaAmount = amount; + return this; + } + + /** + * Set the origin hot key address + * @param {string} address of origin hotkey + * @returns {MoveStakeBuilder} This builder. + */ + originHotkey(address: string): this { + this.validateAddress({ address }); + this._originHotkey = address; + return this; + } + + /** + * Set the destination hot key address + * @param {string} address of destination hotkey + * @returns {MoveStakeBuilder} This builder. + */ + destinationHotkey(address: string): this { + this.validateAddress({ address }); + this._destinationHotkey = address; + return this; + } + + /** + * Set the origin netuid of the subnet (root network is 0) + * @param {string} netuid of subnet + * @returns {MoveStakeBuilder} This builder. + */ + originNetuid(netuid: string): this { + this.validateNetuid(netuid); + this._originNetuid = netuid; + return this; + } + + /** + * Set the destination netuid of the subnet (root network is 0) + * @param {string} netuid of subnet + * @returns {MoveStakeBuilder} This builder. + */ + destinationNetuid(netuid: string): this { + this.validateNetuid(netuid); + this._destinationNetuid = netuid; + return this; + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + if (this._method?.name !== Interface.MethodNames.MoveStake) { + throw new InvalidTransactionError( + `Invalid Transaction Type: ${this._method?.name}. Expected ${Interface.MethodNames.MoveStake}` + ); + } + const tx = super.fromImplementation(rawTransaction); + const txMethod = this._method.args as Interface.MoveStakeArgs; + this.amount(txMethod.alphaAmount); + this.originHotkey(txMethod.originHotkey); + this.destinationHotkey(txMethod.destinationHotkey); + this.originNetuid(txMethod.originNetuid); + this.destinationNetuid(txMethod.destinationNetuid); + return tx; + } + + /** @inheritdoc */ + validateTransaction(_: Transaction): void { + super.validateTransaction(_); + this.validateFields( + this._originHotkey, + this._destinationHotkey, + this._originNetuid, + this._destinationNetuid, + this._alphaAmount + ); + } + + /** + * @param {BigNumber} amount amount to validate + * @throws {InvalidTransactionError} if amount is less than or equal to zero + */ + private validateAmount(amount: BigNumber): void { + if (amount.isLessThanOrEqualTo(0)) { + throw new InvalidTransactionError('Amount must be greater than zero'); + } + } + + /** + * @param {string} netuid netuid to validate + * @throws {InvalidTransactionError} if netuid is out of range + */ + private validateNetuid(netuid: string): void { + const trimmed = netuid.trim(); + + if (!/^\d+$/.test(trimmed)) { + throw new InvalidTransactionError(`Invalid netuid: ${netuid}. Must be a non-negative integer.`); + } + + const num = Number(trimmed); + if (num < 0 || num > TAO_CONSTANTS.MAX_NETUID) { + throw new InvalidTransactionError( + `Invalid netuid: ${netuid}. Netuid must be between 0 and ${TAO_CONSTANTS.MAX_NETUID}.` + ); + } + } + + /** + * Helper method to validate whether tx params have the correct type and format + * @param {string} originHotkey origin hotkey address + * @param {string} destinationHotkey destination hotkey address + * @param {string} originNetuid netuid of the origin subnet + * @param {string} destinationNetuid netuid of the destination subnet + * @param {string} alphaAmount amount to move + * @throws {InvalidTransactionError} if validation fails + */ + private validateFields( + originHotkey: string, + destinationHotkey: string, + originNetuid: string, + destinationNetuid: string, + alphaAmount: string + ): void { + // Validate netuid ranges + this.validateNetuid(originNetuid); + this.validateNetuid(destinationNetuid); + + const validationResult = Schema.MoveStakeTransactionSchema.validate({ + originHotkey, + destinationHotkey, + originNetuid, + destinationNetuid, + alphaAmount, + }); + + if (validationResult.error) { + throw new InvalidTransactionError(`Transaction validation failed: ${validationResult.error.message}`); + } + } + + /** @inheritdoc */ + validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx, rawTransaction: string): void { + if (decodedTxn.method?.name === Interface.MethodNames.MoveStake) { + const txMethod = decodedTxn.method.args as unknown as Interface.MoveStakeArgs; + + const validationResult = Schema.MoveStakeTransactionSchema.validate(txMethod); + if (validationResult.error) { + throw new InvalidTransactionError( + `Move Stake Transaction validation failed: ${validationResult.error.message}` + ); + } + } + } + + /** + * Construct a transaction to move stake + * + * @param {Interface.MoveStakeArgs} args arguments to be passed to the moveStake method + * @param {Interface.CreateBaseTxInfo} info txn info required to construct the moveStake txn + * @returns {UnsignedTransaction} an unsigned move stake transaction + */ + private moveStake(args: Interface.MoveStakeArgs, info: Interface.CreateBaseTxInfo): UnsignedTransaction { + return defineMethod( + { + method: { + args, + name: 'moveStake', + pallet: 'subtensorModule', + }, + ...info.baseTxInfo, + }, + info.options + ); + } +} diff --git a/modules/sdk-coin-tao/src/lib/moveStakeTransaction.ts b/modules/sdk-coin-tao/src/lib/moveStakeTransaction.ts new file mode 100644 index 0000000000..00841e4154 --- /dev/null +++ b/modules/sdk-coin-tao/src/lib/moveStakeTransaction.ts @@ -0,0 +1,80 @@ +import { Interface as SubstrateInterface, Transaction as SubstrateTransaction } from '@bitgo/abstract-substrate'; +import { InvalidTransactionError, TransactionRecipient } from '@bitgo/sdk-core'; +import { decode } from '@substrate/txwrapper-polkadot'; +import { MoveStakeTxData } from './iface'; +import utils from './utils'; + +export class MoveStakeTransaction extends SubstrateTransaction { + /** @inheritdoc */ + toJson(): SubstrateInterface.TxData { + if (!this._substrateTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + + const decodedTx = decode(this._substrateTransaction, { + metadataRpc: this._substrateTransaction.metadataRpc, + registry: this._registry, + isImmortalEra: utils.isZeroHex(this._substrateTransaction.era), + }) as unknown as SubstrateInterface.DecodedTx; + const txMethod = decodedTx.method.args as SubstrateInterface.MoveStakeArgs; + + const result = super.toJson() as MoveStakeTxData; + result.originHotkey = txMethod.originHotkey; + result.destinationHotkey = txMethod.destinationHotkey; + result.originNetuid = txMethod.originNetuid; + result.destinationNetuid = txMethod.destinationNetuid; + result.alphaAmount = txMethod.alphaAmount; + + return result; + } + + /** @inheritdoc */ + loadInputsAndOutputs(): void { + super.loadInputsAndOutputs(); + + const decodedTx = decode(this._substrateTransaction, { + metadataRpc: this._substrateTransaction.metadataRpc, + registry: this._registry, + isImmortalEra: utils.isZeroHex(this._substrateTransaction.era), + }) as unknown as SubstrateInterface.DecodedTx; + const txMethod = decodedTx.method.args as SubstrateInterface.MoveStakeArgs; + + this._inputs.push({ + address: txMethod.originHotkey, + value: txMethod.alphaAmount, + coin: utils.getTaoTokenBySubnetId(txMethod.originNetuid).name, + }); + + this._outputs.push({ + address: txMethod.destinationHotkey, + value: txMethod.alphaAmount, + coin: utils.getTaoTokenBySubnetId(txMethod.destinationNetuid).name, + }); + } + + /** @inheritdoc */ + explainTransaction(): SubstrateInterface.TransactionExplanation { + const result = this.toJson(); + const outputs: TransactionRecipient[] = this._outputs.map((output) => { + return { + address: output.address, + amount: output.value, + tokenName: output.coin, + }; + }); + + const explanationResult: SubstrateInterface.TransactionExplanation = { + id: result.id, + outputAmount: result.amount?.toString() || '0', + changeAmount: '0', + changeOutputs: [], + outputs, + fee: { + fee: result.tip?.toString() || '', + type: 'tip', + }, + type: this.type, + }; + return explanationResult; + } +} diff --git a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts index 2d7332879e..f0af01ce34 100644 --- a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts @@ -7,6 +7,7 @@ import utils from './utils'; import { StakingBuilder } from './stakingBuilder'; import { UnstakeBuilder } from './unstakeBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; +import { MoveStakeBuilder } from './moveStakeBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { protected _material: Interface.Material; @@ -32,6 +33,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new TokenTransferBuilder(this._coinConfig).material(this._material); } + getMoveStakeBuilder(): TransactionBuilder { + return new MoveStakeBuilder(this._coinConfig).material(this._material); + } + getWalletInitializationBuilder(): void { throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`); } @@ -63,6 +68,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getUnstakingBuilder(); } else if (methodName === Interface.MethodNames.TransferStake) { return this.getTokenTransferBuilder(); + } else if (methodName === Interface.MethodNames.MoveStake) { + return this.getMoveStakeBuilder(); } else { throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); } diff --git a/modules/sdk-coin-tao/test/unit/transactionBuilder/moveStakeBuilder.ts b/modules/sdk-coin-tao/test/unit/transactionBuilder/moveStakeBuilder.ts new file mode 100644 index 0000000000..668e156c1c --- /dev/null +++ b/modules/sdk-coin-tao/test/unit/transactionBuilder/moveStakeBuilder.ts @@ -0,0 +1,499 @@ +import assert from 'assert'; +import should from 'should'; +import { assert as SinonAssert, spy } from 'sinon'; +import { MoveStakeBuilder } from '../../../src/lib/moveStakeBuilder'; +import utils from '../../../src/lib/utils'; +import { accounts, mockTssSignature, genesisHash, chainName } from '../../resources'; +import { buildTestConfig } from './base'; +import { testnetMaterial } from '../../../src/resources'; +import { InvalidTransactionError } from '@bitgo/sdk-core'; + +// Test helper class to access private methods for testing +class TestMoveStakeBuilder extends MoveStakeBuilder { + setMethodForTesting(method: any): void { + this._method = method; + } +} + +describe('Tao Move Stake Builder', function () { + const referenceBlock = '0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d'; + let builder: MoveStakeBuilder; + const sender = accounts.account1; + + beforeEach(function () { + const config = buildTestConfig(); + const material = utils.getMaterial(config.network.type); + builder = new MoveStakeBuilder(config).material(material); + }); + + describe('setter validation', function () { + it('should validate amount', function () { + assert.throws( + () => builder.amount('-1'), + (e: Error) => e.message === 'Amount must be greater than zero' + ); + assert.throws( + () => builder.amount('0'), + (e: Error) => e.message === 'Amount must be greater than zero' + ); + should.doesNotThrow(() => builder.amount('1000')); + should.doesNotThrow(() => builder.amount('1')); + }); + + it('should validate addresses', function () { + const spyValidateAddress = spy(builder, 'validateAddress'); + assert.throws( + () => builder.originHotkey('abc'), + (e: Error) => e.message === `The address 'abc' is not a well-formed dot address` + ); + assert.throws( + () => builder.destinationHotkey('abc'), + (e: Error) => e.message === `The address 'abc' is not a well-formed dot address` + ); + should.doesNotThrow(() => builder.originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT')); + should.doesNotThrow(() => builder.destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq')); + + SinonAssert.callCount(spyValidateAddress, 4); + }); + }); + + describe('build move stake transaction', function () { + it('should build a move stake transaction', async function () { + builder + .amount('9007199254740995') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1') + .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(); + + txJson.should.have.properties([ + 'id', + 'sender', + 'referenceBlock', + 'blockNumber', + 'genesisHash', + 'nonce', + 'specVersion', + 'transactionVersion', + 'eraPeriod', + 'chainName', + 'tip', + 'originHotkey', + 'destinationHotkey', + 'originNetuid', + 'destinationNetuid', + 'alphaAmount', + ]); + + txJson.sender.should.equal('5EGoFA95omzemRssELLDjVenNZ68aXyUeqtKQScXSEBvVJkr'); + txJson.originHotkey.should.equal('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + txJson.destinationHotkey.should.equal('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq'); + txJson.originNetuid.should.equal('1'); + txJson.destinationNetuid.should.equal('1'); + txJson.alphaAmount.should.equal('9007199254740995'); + txJson.blockNumber.should.equal(3933); + txJson.nonce.should.equal(200); + txJson.tip.should.equal(0); + + // Verify transaction explanation + const explanation = tx.explainTransaction(); + explanation.should.have.properties(['outputs', 'outputAmount', 'changeAmount', 'fee']); + explanation.outputs.should.have.length(1); + explanation.outputs[0].should.deepEqual({ + address: '5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq', + amount: '9007199254740995', + tokenName: utils.getTaoTokenBySubnetId('1').name, + }); + }); + + it('should validate required fields', function () { + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should set and get origin netuid', function () { + builder.originNetuid('5'); + // We can't directly access private fields, but we can verify through building + should.doesNotThrow(() => builder.originNetuid('5')); + }); + + it('should set and get destination netuid', function () { + builder.destinationNetuid('10'); + // We can't directly access private fields, but we can verify through building + should.doesNotThrow(() => builder.destinationNetuid('10')); + }); + + it('should build transaction with different netuids', async function () { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('2') + .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(); + + txJson.originNetuid.should.equal('1'); + txJson.destinationNetuid.should.equal('2'); + txJson.alphaAmount.should.equal('1000000000000'); + + const explanation = tx.explainTransaction(); + explanation.outputs[0].tokenName.should.equal('ttao:onion'); + }); + }); + + describe('validation', function () { + it('should validate move stake transaction schema', function () { + should.doesNotThrow(() => { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1'); + }); + }); + + it('should throw error for invalid amount', function () { + assert.throws( + () => builder.amount('-100'), + (e: Error) => e.message.includes('Amount must be greater than zero') + ); + }); + + it('should throw error for zero amount', function () { + assert.throws( + () => builder.amount('0'), + (e: Error) => e.message === 'Amount must be greater than zero' + ); + }); + + it('should validate netuid range for origin netuid', function () { + // Valid netuids + should.doesNotThrow(() => builder.originNetuid('0')); + should.doesNotThrow(() => builder.originNetuid('64')); + should.doesNotThrow(() => builder.originNetuid('128')); + + // Invalid netuids + assert.throws( + () => builder.originNetuid('-1'), + (e: Error) => e.message.includes('Invalid netuid: -1. Must be a non-negative integer.') + ); + assert.throws( + () => builder.originNetuid('129'), + (e: Error) => e.message.includes('Invalid netuid: 129. Netuid must be between 0 and 128.') + ); + assert.throws( + () => builder.originNetuid('abc'), + (e: Error) => e.message.includes('Invalid netuid: abc. Must be a non-negative integer.') + ); + }); + + it('should validate netuid range for destination netuid', function () { + // Valid netuids + should.doesNotThrow(() => builder.destinationNetuid('0')); + should.doesNotThrow(() => builder.destinationNetuid('64')); + should.doesNotThrow(() => builder.destinationNetuid('128')); + + // Invalid netuids + assert.throws( + () => builder.destinationNetuid('-1'), + (e: Error) => e.message.includes('Invalid netuid: -1. Must be a non-negative integer.') + ); + assert.throws( + () => builder.destinationNetuid('129'), + (e: Error) => e.message.includes('Invalid netuid: 129. Netuid must be between 0 and 128.') + ); + assert.throws( + () => builder.destinationNetuid('invalid'), + (e: Error) => e.message.includes('Invalid netuid: invalid. Must be a non-negative integer.') + ); + }); + }); + + describe('TSS signature integration', function () { + it('should build a signed move stake transaction with TSS signature', async function () { + builder + .amount('9007199254740995') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1') + .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(); + + txJson.alphaAmount.should.equal('9007199254740995'); + txJson.originHotkey.should.equal('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + txJson.destinationHotkey.should.equal('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq'); + txJson.originNetuid.should.equal('1'); + txJson.destinationNetuid.should.equal('1'); + txJson.sender.should.equal(sender.address); + txJson.blockNumber.should.equal(3933); + txJson.referenceBlock.should.equal(referenceBlock); + txJson.genesisHash.should.equal(genesisHash); + txJson.specVersion.should.equal(Number(testnetMaterial.specVersion)); + txJson.nonce.should.equal(200); + txJson.tip.should.equal(0); + txJson.transactionVersion.should.equal(Number(testnetMaterial.txVersion)); + txJson.chainName.toLowerCase().should.equal(chainName); + txJson.eraPeriod.should.equal(64); + }); + + it('should build an unsigned move stake transaction', async function () { + builder + .amount('50000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('2') + .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(); + + txJson.alphaAmount.should.equal('50000000'); + txJson.originHotkey.should.equal('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'); + txJson.destinationHotkey.should.equal('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq'); + txJson.originNetuid.should.equal('1'); + txJson.destinationNetuid.should.equal('2'); + txJson.sender.should.equal(sender.address); + txJson.blockNumber.should.equal(3933); + txJson.referenceBlock.should.equal(referenceBlock); + txJson.genesisHash.should.equal(genesisHash); + txJson.specVersion.should.equal(Number(testnetMaterial.specVersion)); + txJson.nonce.should.equal(200); + txJson.tip.should.equal(0); + txJson.transactionVersion.should.equal(Number(testnetMaterial.txVersion)); + txJson.chainName.toLowerCase().should.equal(chainName); + txJson.eraPeriod.should.equal(64); + }); + }); + + describe('comprehensive error handling', function () { + it('should throw error for missing origin hotkey', function () { + builder + .amount('1000000000000') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1'); + + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should throw error for missing destination hotkey', function () { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .originNetuid('1') + .destinationNetuid('1'); + + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should throw error for missing origin netuid', function () { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .destinationNetuid('1'); + + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should throw error for missing destination netuid', function () { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1'); + + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should throw error for missing amount', function () { + builder + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1'); + + assert.throws( + () => builder.validateTransaction({} as any), + (e: Error) => e.message.includes('Transaction validation failed') + ); + }); + + it('should throw error for invalid transaction type in fromImplementation', function () { + const config = buildTestConfig(); + const material = utils.getMaterial(config.network.type); + const mockBuilder = new TestMoveStakeBuilder(config).material(material); + mockBuilder.setMethodForTesting({ + name: 'transferKeepAlive', + args: { dest: { id: 'test' }, value: '1000' }, + pallet: 'balances', + }); + + assert.throws( + () => { + // Call the validation logic directly + if (mockBuilder['_method']?.name !== 'moveStake') { + throw new InvalidTransactionError( + `Invalid Transaction Type: ${mockBuilder['_method']?.name}. Expected moveStake` + ); + } + }, + (e: Error) => e.message.includes('Invalid Transaction Type: transferKeepAlive. Expected moveStake') + ); + }); + + it('should handle malformed raw transaction', function () { + assert.throws( + () => builder.from('invalid_hex_data'), + (e: Error) => e.message !== undefined + ); + }); + }); + + describe('boundary value and edge case tests', function () { + it('should handle very large amounts', function () { + const largeAmount = '999999999999999999999999999999'; + should.doesNotThrow(() => builder.amount(largeAmount)); + }); + + it('should handle same origin and destination hotkeys', function () { + const sameAddress = '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT'; + should.doesNotThrow(() => { + builder.originHotkey(sameAddress).destinationHotkey(sameAddress); + }); + }); + + it('should handle same origin and destination netuids', function () { + should.doesNotThrow(() => { + builder.originNetuid('5').destinationNetuid('5'); + }); + }); + + it('should validate various address formats', function () { + const invalidAddresses = [ + '', + '123', + 'invalid_address_format', + '5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT_invalid', + ]; + + invalidAddresses.forEach((address) => { + assert.throws( + () => builder.originHotkey(address), + (e: Error) => e.message.includes('is not a well-formed dot address') + ); + assert.throws( + () => builder.destinationHotkey(address), + (e: Error) => e.message.includes('is not a well-formed dot address') + ); + }); + }); + + it('should handle boundary netuid values', function () { + should.doesNotThrow(() => builder.originNetuid('0')); + should.doesNotThrow(() => builder.originNetuid('128')); + should.doesNotThrow(() => builder.destinationNetuid('0')); + should.doesNotThrow(() => builder.destinationNetuid('128')); + + assert.throws( + () => builder.originNetuid('-1'), + (e: Error) => e.message.includes('Invalid netuid: -1. Must be a non-negative integer.') + ); + assert.throws( + () => builder.destinationNetuid('129'), + (e: Error) => e.message.includes('Invalid netuid: 129. Netuid must be between 0 and 128.') + ); + }); + }); + + describe('transaction explanation validation', function () { + it('should provide correct explanation with different subnet tokens', async function () { + builder + .amount('1000000000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('2') + .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 explanation = tx.explainTransaction(); + + explanation.should.have.properties(['outputs', 'outputAmount', 'changeAmount', 'fee', 'type']); + explanation.outputs.should.have.length(1); + explanation.outputs[0].should.have.properties(['address', 'amount', 'tokenName']); + explanation.outputs[0].address.should.equal('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq'); + explanation.outputs[0].amount.should.equal('1000000000000'); + explanation.changeAmount.should.equal('0'); + explanation.fee.should.have.properties(['fee', 'type']); + explanation.fee.type.should.equal('tip'); + }); + + it('should handle explanation with zero tip', async function () { + builder + .amount('500000000') + .originHotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .destinationHotkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .originNetuid('1') + .destinationNetuid('1') + .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 explanation = tx.explainTransaction(); + + explanation.fee.fee.should.equal('0'); + explanation.outputAmount.should.equal('0'); + }); + }); +});