Skip to content

feat(sdk-coin-tao): add tokenTransferBuilder #6681

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion modules/abstract-substrate/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
*/
Expand All @@ -197,7 +210,8 @@ export interface TxMethod {
| ChillArgs
| UnbondArgs
| WithdrawUnbondedArgs
| BatchArgs;
| BatchArgs
| TransferStakeArgs;
name: MethodNamesValues;
pallet: string;
}
Expand Down
8 changes: 8 additions & 0 deletions modules/abstract-substrate/src/lib/txnSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
187 changes: 187 additions & 0 deletions modules/sdk-coin-tao/src/lib/tokenTransferBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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
);
}
}
7 changes: 7 additions & 0 deletions modules/sdk-coin-tao/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`);
}
Expand Down Expand Up @@ -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');
}
Expand Down
4 changes: 4 additions & 0 deletions modules/sdk-coin-tao/test/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ export const rawTx = {
unsigned:
'0x9806129f7b0675db59d19b4bd9c8c72eaabba75a9863d02b30115b8b3c3ca5c20f025422130000d501210300c823000009000000e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e149799bc9602cb5cf201f3425fb8d253b2d4e61fc119dcab3249f307f594754d',
},
transferStake: {
unsigned:
'0xdd02840061b18c6dc02ddcabdeac56cb4f21a971cc41cc97640f6f85b073480008c53a0d00aadae7fa1f53e7a5c900b330ff71bee6782cf3c29a2c6f9599162381cd021ad581c74ded89f49ec79adefed64af8ff16649553523dda9cb4f017cbf15681e50ed5012103000007569f7b0675db59d19b4bd9c8c72eaabba75a9863d02b30115b8b3c3ca5c20f02548a90be061598f4b592afbd546bcb6beadb3c02f5c129df2e11b698f9543dbd41010002000300000000002000',
},
};

export const jsonTransactions = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading