Skip to content
Open
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
11 changes: 10 additions & 1 deletion modules/abstract-substrate/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export const MethodNames = {
* Move stake from one hotkey to another.
*/
MoveStake: 'moveStake' as const,
/**
* Claim root network rewards with a hotkey.
*/
ClaimRootWithHotkey: 'claimRootWithHotkey' as const,
} as const;

/**
Expand Down Expand Up @@ -207,6 +211,10 @@ export interface MoveStakeArgs extends Args {
alphaAmount: string;
}

export interface ClaimRootWithHotkeyArgs extends Args {
hotkey: string;
}

/**
* Decoded TxMethod from a transaction hex
*/
Expand All @@ -224,7 +232,8 @@ export interface TxMethod {
| WithdrawUnbondedArgs
| BatchArgs
| TransferStakeArgs
| MoveStakeArgs;
| MoveStakeArgs
| ClaimRootWithHotkeyArgs;
name: MethodNamesValues;
pallet: string;
}
Expand Down
4 changes: 4 additions & 0 deletions modules/abstract-substrate/src/lib/txnSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@ export const MoveStakeTransactionSchema = joi.object({
destinationNetuid: joi.string().required(),
alphaAmount: joi.string().required(),
});

export const ClaimRootWithHotkeyTransactionSchema = joi.object({
hotkey: addressSchema.required(),
});
90 changes: 90 additions & 0 deletions modules/sdk-coin-tao/src/lib/claimRootBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Interface, Schema, Transaction, TransactionBuilder } from '@bitgo/abstract-substrate';
import { InvalidTransactionError, TransactionType, BaseAddress } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { DecodedSignedTx, DecodedSigningPayload, defineMethod, UnsignedTransaction } from '@substrate/txwrapper-core';
import { ClaimRootTransaction } from './claimRootTransaction';

export class ClaimRootBuilder extends TransactionBuilder {
protected _hotkey: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
this._transaction = new ClaimRootTransaction(_coinConfig);
}

/** @inheritdoc */
protected buildTransaction(): UnsignedTransaction {
const baseTxInfo = this.createBaseTxInfo();
return this.claimRootWithHotkey({ hotkey: this._hotkey }, baseTxInfo);
}

/** @inheritdoc */
protected get transactionType(): TransactionType {
return TransactionType.StakingClaim;
}

/**
* Set the hotkey address for the claim
* @param {string} address hotkey address to claim rewards for
* @returns {ClaimRootBuilder} This builder.
*/
hotkey({ address }: BaseAddress): this {
this.validateAddress({ address });
this._hotkey = address;
return this;
}

/** @inheritdoc */
protected fromImplementation(rawTransaction: string): Transaction {
const tx = super.fromImplementation(rawTransaction);
if (this._method?.name !== Interface.MethodNames.ClaimRootWithHotkey) {
throw new InvalidTransactionError(
`Invalid Transaction Type: ${this._method?.name}. Expected ${Interface.MethodNames.ClaimRootWithHotkey}`
);
}
const txMethod = this._method.args as Interface.ClaimRootWithHotkeyArgs;
this.hotkey({ address: txMethod.hotkey });
return tx;
}

/** @inheritdoc */
validateTransaction(_: Transaction): void {
super.validateTransaction(_);
this.validateFields(this._hotkey);
}

private validateFields(hotkey: string): void {
const validationResult = Schema.ClaimRootWithHotkeyTransactionSchema.validate({ hotkey });
if (validationResult.error) {
throw new InvalidTransactionError(`ClaimRoot Transaction validation failed: ${validationResult.error.message}`);
}
}

/** @inheritdoc */
validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void {
if (decodedTxn.method?.name === Interface.MethodNames.ClaimRootWithHotkey) {
const txMethod = decodedTxn.method.args as unknown as Interface.ClaimRootWithHotkeyArgs;
const validationResult = Schema.ClaimRootWithHotkeyTransactionSchema.validate({ hotkey: txMethod.hotkey });
if (validationResult.error) {
throw new InvalidTransactionError(`ClaimRoot Transaction validation failed: ${validationResult.error.message}`);
}
}
}

private claimRootWithHotkey(
args: Interface.ClaimRootWithHotkeyArgs,
info: Interface.CreateBaseTxInfo
): UnsignedTransaction {
return defineMethod(
{
method: {
args,
name: 'claimRootWithHotkey',
pallet: 'subtensorModule',
},
...info.baseTxInfo,
},
info.options
);
}
}
68 changes: 68 additions & 0 deletions modules/sdk-coin-tao/src/lib/claimRootTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Interface as SubstrateInterface, Transaction as SubstrateTransaction } from '@bitgo/abstract-substrate';
import { InvalidTransactionError } from '@bitgo/sdk-core';
import { decode } from '@substrate/txwrapper-polkadot';
import { ClaimRootTxData } from './iface';
import utils from './utils';

export class ClaimRootTransaction 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.ClaimRootWithHotkeyArgs;

const result = super.toJson() as ClaimRootTxData;
result.hotkey = txMethod.hotkey;

return result;
}

/** @inheritdoc */
loadInputsAndOutputs(): void {
if (!this._substrateTransaction) {
return;
}

this._outputs = [];
this._inputs = [];

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.ClaimRootWithHotkeyArgs;

this._outputs.push({
address: txMethod.hotkey,
value: '0',
});
}

/** @inheritdoc */
explainTransaction(): SubstrateInterface.TransactionExplanation {
const result = this.toJson() as ClaimRootTxData;

return {
id: result.id,
outputAmount: '0',
changeAmount: '0',
changeOutputs: [],
outputs: [{ address: result.hotkey, amount: '0' }],
fee: {
fee: result.tip?.toString() || '0',
type: 'tip',
},
type: this.type,
};
}
}
4 changes: 4 additions & 0 deletions modules/sdk-coin-tao/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ export interface MoveStakeTxData extends Interface.TxData {
destinationNetuid: string;
alphaAmount: string;
}

export interface ClaimRootTxData extends Interface.TxData {
hotkey: string;
}
3 changes: 3 additions & 0 deletions modules/sdk-coin-tao/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ export { StakingBuilder } from './stakingBuilder';
export { UnstakeBuilder } from './unstakeBuilder';
export { MoveStakeBuilder } from './moveStakeBuilder';
export { MoveStakeTransaction } from './moveStakeTransaction';
export { ClaimRootBuilder } from './claimRootBuilder';
export { ClaimRootTransaction } from './claimRootTransaction';
export type { ClaimRootTxData } from './iface';
export { Utils, default as utils } from './utils';
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 @@ -8,6 +8,7 @@ import { StakingBuilder } from './stakingBuilder';
import { UnstakeBuilder } from './unstakeBuilder';
import { TokenTransferBuilder } from './tokenTransferBuilder';
import { MoveStakeBuilder } from './moveStakeBuilder';
import { ClaimRootBuilder } from './claimRootBuilder';

export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
protected _material: Interface.Material;
Expand Down Expand Up @@ -37,6 +38,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return new MoveStakeBuilder(this._coinConfig).material(this._material);
}

getClaimRootBuilder(): ClaimRootBuilder {
return new ClaimRootBuilder(this._coinConfig).material(this._material);
}

getWalletInitializationBuilder(): void {
throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`);
}
Expand Down Expand Up @@ -70,6 +75,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return this.getTokenTransferBuilder();
} else if (methodName === Interface.MethodNames.MoveStake) {
return this.getMoveStakeBuilder();
} else if (methodName === Interface.MethodNames.ClaimRootWithHotkey) {
return this.getClaimRootBuilder();
} else {
throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type');
}
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-tao/src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './mainnet';
export * from './testnet';
export * from './testnetV2';
13 changes: 13 additions & 0 deletions modules/sdk-coin-tao/src/resources/testnetV2.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions modules/sdk-coin-tao/src/tao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
BitGoBase,
Environments,
SignTransactionOptions as BaseSignTransactionOptions,
VerifyTransactionOptions,
} from '@bitgo/sdk-core';
import { coins, BaseCoin as StaticsBaseCoin, SubstrateSpecNameType } from '@bitgo/statics';
import { Interface, SubstrateCoin } from '@bitgo/abstract-substrate';
Expand Down Expand Up @@ -55,6 +56,16 @@ export class Tao extends SubstrateCoin {
return new TransactionBuilderFactory(coins.get(this.getChain()));
}

/** @inheritDoc */
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
// claimRootWithHotkey carries no transfer recipient — skip recipient validation.
// Compare case-insensitively: WalletPlatform may deliver intentType as lowercase.
if (params.txParams?.type?.toLowerCase() === 'stakingclaim') {
return true;
}
return super.verifyTransaction(params);
}

getMaxValidityDurationBlocks(): number {
return 2400;
}
Expand Down
27 changes: 27 additions & 0 deletions modules/sdk-coin-tao/test/unit/tao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ describe('Tao:', function () {
const transferAmount = '2';
const sweepTo = '5EQZSJmHuFH8asYYJruSRwpJmE5aqSdhdiX9oxRbxujKUkTe';
const wrongAddress = '5Ffp1wJCPu4hzVDTo7XaMLqZSvSadyUQmxWPDw74CBjECSoq';
const claimSignedHex = rawTx.claim.signed;

describe('transfer transaction', function () {
it('should return true when address and amount match', async function () {
Expand Down Expand Up @@ -563,6 +564,32 @@ describe('Tao:', function () {
});
});

describe('claimRoot transaction', function () {
it('should return true for StakingClaim type without decoding the tx', async function () {
const result = await baseCoin.verifyTransaction({
txPrebuild: { txHex: claimSignedHex },
txParams: { type: 'StakingClaim' },
});
result.should.be.true();
});

it('should return true for lowercase stakingclaim type (WalletPlatform intentType)', async function () {
const result = await baseCoin.verifyTransaction({
txPrebuild: { txHex: claimSignedHex },
txParams: { type: 'stakingclaim' },
});
result.should.be.true();
});

it('should return true for transfer tx with no recipients provided', async function () {
const result = await baseCoin.verifyTransaction({
txPrebuild: { txHex: rawTx.transfer.signed },
txParams: {},
});
result.should.be.true();
});
});

describe('guard cases', function () {
it('should throw when txHex is missing', async function () {
await baseCoin
Expand Down
Loading
Loading