diff --git a/modules/abstract-substrate/src/lib/iface.ts b/modules/abstract-substrate/src/lib/iface.ts index 4a578a0765..da7b86257c 100644 --- a/modules/abstract-substrate/src/lib/iface.ts +++ b/modules/abstract-substrate/src/lib/iface.ts @@ -74,6 +74,11 @@ export const MethodNames = { * Send a batch of dispatch calls and atomically execute them. */ BatchAll: 'batchAll' as const, + + /** + * Transfer stake from one validator to another. + */ + TransferStake: 'transferStake' as const, } as const; /** @@ -182,6 +187,14 @@ export interface BatchArgs { calls: BatchCallObject[]; } +export interface TransferStakeArgs extends Args { + destinationColdkey: string; + hotkey: string; + originNetuid: string; + destinationNetuid: string; + alphaAmount: string; +} + /** * Decoded TxMethod from a transaction hex */ @@ -197,7 +210,8 @@ export interface TxMethod { | ChillArgs | UnbondArgs | WithdrawUnbondedArgs - | BatchArgs; + | BatchArgs + | TransferStakeArgs; name: MethodNamesValues; pallet: string; } diff --git a/modules/abstract-substrate/src/lib/txnSchema.ts b/modules/abstract-substrate/src/lib/txnSchema.ts index 0c5647103a..b023d83c1f 100644 --- a/modules/abstract-substrate/src/lib/txnSchema.ts +++ b/modules/abstract-substrate/src/lib/txnSchema.ts @@ -57,3 +57,11 @@ export const UnstakeTransactionSchema = joi.object({ hotkey: joi.string().required(), netuid: joi.string().required(), }); + +export const TransferStakeTransactionSchema = joi.object({ + destinationColdkey: addressSchema.required(), + hotkey: addressSchema.required(), + originNetuid: joi.string().required(), + destinationNetuid: joi.string().required(), + alphaAmount: joi.string().required(), +}); diff --git a/modules/sdk-coin-tao/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-tao/src/lib/tokenTransferBuilder.ts new file mode 100644 index 0000000000..8c57417ef8 --- /dev/null +++ b/modules/sdk-coin-tao/src/lib/tokenTransferBuilder.ts @@ -0,0 +1,187 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { defineMethod, UnsignedTransaction, DecodedSignedTx, DecodedSigningPayload } from '@substrate/txwrapper-core'; +import BigNumber from 'bignumber.js'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder, Interface, Schema, Transaction } from '@bitgo/abstract-substrate'; + +export class TokenTransferBuilder extends TransactionBuilder { + protected _destinationColdkey: string; + protected _hotkey: string; + protected _originNetuid: string; + protected _destinationNetuid: string; + protected _alphaAmount: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** + * Construct a transaction to transfer stake + * @returns {UnsignedTransaction} an unsigned transfer stake transaction + */ + protected buildTransaction(): UnsignedTransaction { + const baseTxInfo = this.createBaseTxInfo(); + return this.transferStake( + { + destinationColdkey: this._destinationColdkey, + hotkey: this._hotkey, + originNetuid: this._originNetuid, + destinationNetuid: this._destinationNetuid, + alphaAmount: this._alphaAmount, + }, + baseTxInfo + ); + } + + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.SendToken; + } + + /** + * Set the amount to transfer + * @param {string} amount to transfer + * @returns {TokenTransferBuilder} This builder. + */ + amount(amount: string): this { + this.validateValue(new BigNumber(amount)); + this._alphaAmount = amount; + return this; + } + + /** + * Set the validator hot key address + * @param {string} hotkey address of validator + * @returns {TokenTransferBuilder} This builder. + */ + hotkey(address: string): this { + this.validateAddress({ address }); + this._hotkey = address; + return this; + } + + /** + * Set the destination cold key address + * @param {string} address of the destination cold key + * @returns {TokenTransferBuilder} This builder. + */ + + destinationColdkey(address: string): this { + this.validateAddress({ address }); + this._destinationColdkey = address; + return this; + } + + /** + * Set the origin netuid of the subnet (root network is 0) + * @param {string} netuid of subnet + * @returns {TokenTransferBuilder} This builder. + */ + originNetuid(netuid: string): this { + this._originNetuid = netuid; + return this; + } + + /** + * Set the destination netuid of the subnet (root network is 0) + * @param {string} netuid of subnet + * @returns {TokenTransferBuilder} This builder. + */ + destinationNetuid(netuid: string): this { + this._destinationNetuid = netuid; + return this; + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + const tx = super.fromImplementation(rawTransaction); + if (this._method?.name === Interface.MethodNames.TransferStake) { + const txMethod = this._method.args as Interface.TransferStakeArgs; + this.amount(txMethod.alphaAmount); + this.hotkey(txMethod.hotkey); + this.destinationColdkey(txMethod.destinationColdkey); + this.originNetuid(txMethod.originNetuid); + this.destinationNetuid(txMethod.destinationNetuid); + } else { + throw new InvalidTransactionError( + `Invalid Transaction Type: ${this._method?.name}. Expected ${Interface.MethodNames.TransferStake}` + ); + } + return tx; + } + + /** @inheritdoc */ + validateTransaction(_: Transaction): void { + super.validateTransaction(_); + this.validateFields( + this._destinationColdkey, + this._hotkey, + this._originNetuid, + this._destinationNetuid, + this._alphaAmount + ); + } + + /** + * Helper method to validate whether tx params have the correct type and format + * @param {string} destinationColdkey destination cold key address + * @param {string} hotkey hotkey address of the validator + * @param {string} originNetuid netuid of the origin subnet + * @param {string} destinationNetuid netuid of the destination subnet + * @param {string} alphaAmount amount to transfer + * @throws {InvalidTransactionError} if validation fails + */ + private validateFields( + destinationColdkey: string, + hotkey: string, + originNetuid: string, + destinationNetuid: string, + alphaAmount: string + ): void { + const validationResult = Schema.TransferStakeTransactionSchema.validate({ + destinationColdkey, + hotkey, + 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.TransferStake) { + const txMethod = decodedTxn.method.args as unknown as Interface.TransferStakeArgs; + + const validationResult = Schema.TransferStakeTransactionSchema.validate(txMethod); + if (validationResult.error) { + throw new InvalidTransactionError(`Transfer Transaction validation failed: ${validationResult.error.message}`); + } + } + } + + /** + * Construct a transaction to transfer stake + * + * @param {Interface.TransferStakeArgs} args arguments to be passed to the transferStake method + * @param {Interface.CreateBaseTxInfo} info txn info required to construct the transferStake txn + * @returns {UnsignedTransaction} an unsigned stake transaction + */ + + private transferStake(args: Interface.TransferStakeArgs, info: Interface.CreateBaseTxInfo): UnsignedTransaction { + return defineMethod( + { + method: { + args, + name: 'transferStake', + pallet: 'subtensorModule', + }, + ...info.baseTxInfo, + }, + info.options + ); + } +} diff --git a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts index 818fa8e308..2d7332879e 100644 --- a/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts @@ -6,6 +6,7 @@ import { TransferBuilder } from './transferBuilder'; import utils from './utils'; import { StakingBuilder } from './stakingBuilder'; import { UnstakeBuilder } from './unstakeBuilder'; +import { TokenTransferBuilder } from './tokenTransferBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { protected _material: Interface.Material; @@ -27,6 +28,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new UnstakeBuilder(this._coinConfig).material(this._material); } + getTokenTransferBuilder(): TransactionBuilder { + return new TokenTransferBuilder(this._coinConfig).material(this._material); + } + getWalletInitializationBuilder(): void { throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`); } @@ -56,6 +61,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getStakingBuilder(); } else if (methodName === Interface.MethodNames.RemoveStake) { return this.getUnstakingBuilder(); + } else if (methodName === Interface.MethodNames.TransferStake) { + return this.getTokenTransferBuilder(); } else { throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); } diff --git a/modules/sdk-coin-tao/test/resources/index.ts b/modules/sdk-coin-tao/test/resources/index.ts index d92322f24b..e706e0c6a3 100644 --- a/modules/sdk-coin-tao/test/resources/index.ts +++ b/modules/sdk-coin-tao/test/resources/index.ts @@ -175,6 +175,10 @@ export const rawTx = { unsigned: '0x9806129f7b0675db59d19b4bd9c8c72eaabba75a9863d02b30115b8b3c3ca5c20f025422130000d501210300c823000009000000e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d', }, + transferStake: { + unsigned: + '0xdd02840061b18c6dc02ddcabdeac56cb4f21a971cc41cc97640f6f85b073480008c53a0d00aadae7fa1f53e7a5c900b330ff71bee6782cf3c29a2c6f9599162381cd021ad581c74ded89f49ec79adefed64af8ff16649553523dda9cb4f017cbf15681e50ed5012103000007569f7b0675db59d19b4bd9c8c72eaabba75a9863d02b30115b8b3c3ca5c20f02548a90be061598f4b592afbd546bcb6beadb3c02f5c129df2e11b698f9543dbd41010002000300000000002000', + }, }; export const jsonTransactions = { diff --git a/modules/sdk-coin-tao/test/unit/transactionBuilder/tokenTransferBuilder.ts b/modules/sdk-coin-tao/test/unit/transactionBuilder/tokenTransferBuilder.ts new file mode 100644 index 0000000000..4decb8aa5b --- /dev/null +++ b/modules/sdk-coin-tao/test/unit/transactionBuilder/tokenTransferBuilder.ts @@ -0,0 +1,76 @@ +import assert from 'assert'; +import should from 'should'; +import { spy, assert as SinonAssert } from 'sinon'; +import { accounts, mockTssSignature, rawTx } from '../../resources'; +import { buildTestConfig } from './base'; +import utils from '../../../src/lib/utils'; +import { TokenTransferBuilder } from '../../../src/lib/tokenTransferBuilder'; + +describe('Tao Token Transfer Builder', function () { + const referenceBlock = '0x149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d'; + let builder: TokenTransferBuilder; + const sender = accounts.account1; + + beforeEach(function () { + const config = buildTestConfig(); + const material = utils.getMaterial(config.network.type); + builder = new TokenTransferBuilder(config).material(material); + }); + + describe('setter validation', function () { + it('should validate 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 address', function () { + const spyValidateAddress = spy(builder, 'validateAddress'); + assert.throws( + () => builder.hotkey('abc'), + (e: Error) => e.message === `The address 'abc' is not a well-formed dot address` + ); + assert.throws( + () => builder.destinationColdkey('abc'), + (e: Error) => e.message === `The address 'abc' is not a well-formed dot address` + ); + should.doesNotThrow(() => builder.hotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT')); + should.doesNotThrow(() => builder.destinationColdkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT')); + + SinonAssert.callCount(spyValidateAddress, 4); + }); + }); + + describe('build transfer stake transaction', function () { + it('should build a transfer stake transaction', async function () { + builder + .amount('9007199254740995') + .destinationColdkey('5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq') + .hotkey('5FCPTnjevGqAuTttetBy4a24Ej3pH9fiQ8fmvP1ZkrVsLUoT') + .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' }) + .addSignature({ pub: sender.publicKey }, Buffer.from(mockTssSignature, 'hex')); + + const tx = await builder.build(); + const serializedTx = tx.toBroadcastFormat(); + serializedTx.should.equal(rawTx.transferStake.unsigned); + }); + + it('should re-build from raw signed tx', async function () { + builder.from(rawTx.transferStake.unsigned); + builder.validity({ firstValid: 3933, maxDuration: 64 }).referenceBlock(referenceBlock); + const tx = await builder.build(); + const serializedTx = tx.toBroadcastFormat(); + serializedTx.should.equal(rawTx.transferStake.unsigned); + }); + }); +});