From da400f42a370a8c16159427ca94d5f6439d0e16a Mon Sep 17 00:00:00 2001 From: Abhijit Madhusudan <136959837+abhijit0943@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:06:02 +0000 Subject: [PATCH] feat(sdk-coin-polyx): add v8 staking builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polymesh v8 changed staking.bond from bond(controller, value, payee) to bond(value, payee) — the stash is its own controller. Add V8BatchStakingBuilder that builds utility.batchAll([bond, nominate]) with the 2-arg v8 bond against v8 chain metadata, plus thin v8 metadata wrappers for bondExtra, unbond, batch unstaking, withdrawUnbonded, and nominate (logic unchanged, v8 material only). The v7 BatchStakingBuilder and sibling builders are retained as the Flipt rollback path and marked [CLEANUP-V8-OLD]. The factory exposes getV8* methods and from() now routes v8-encoded staking transactions (which cannot be decoded with v7 material) to the matching v8 builder. Verified the built batchAll method hex is byte-for-byte identical to the testnet sandbox tx (block 24829135) and decodes to bond({ value, payee }) with no controller at specVersion 8000000. Ticket: SI-915 --- .../src/lib/batchStakingBuilder.ts | 3 + .../src/lib/batchUnstakingBuilder.ts | 2 + .../src/lib/bondExtraBuilder.ts | 2 + modules/sdk-coin-polyx/src/lib/iface.ts | 13 + modules/sdk-coin-polyx/src/lib/index.ts | 8 +- .../sdk-coin-polyx/src/lib/nominateBuilder.ts | 2 + .../src/lib/transactionBuilderFactory.ts | 101 +++++- modules/sdk-coin-polyx/src/lib/txnSchema.ts | 57 +++ .../sdk-coin-polyx/src/lib/unbondBuilder.ts | 2 + .../src/lib/v8BatchStakingBuilder.ts | 332 ++++++++++++++++++ .../src/lib/v8BatchUnstakingBuilder.ts | 15 + .../src/lib/v8BondExtraBuilder.ts | 15 + .../src/lib/v8NominateBuilder.ts | 15 + .../sdk-coin-polyx/src/lib/v8UnbondBuilder.ts | 15 + .../src/lib/v8WithdrawUnbondedBuilder.ts | 15 + .../src/lib/withdrawUnbondedBuilder.ts | 2 + .../sdk-coin-polyx/test/resources/index.ts | 31 ++ .../v8BatchStakingBuilder.ts | 236 +++++++++++++ 18 files changed, 861 insertions(+), 5 deletions(-) create mode 100644 modules/sdk-coin-polyx/src/lib/v8BatchStakingBuilder.ts create mode 100644 modules/sdk-coin-polyx/src/lib/v8BatchUnstakingBuilder.ts create mode 100644 modules/sdk-coin-polyx/src/lib/v8BondExtraBuilder.ts create mode 100644 modules/sdk-coin-polyx/src/lib/v8NominateBuilder.ts create mode 100644 modules/sdk-coin-polyx/src/lib/v8UnbondBuilder.ts create mode 100644 modules/sdk-coin-polyx/src/lib/v8WithdrawUnbondedBuilder.ts create mode 100644 modules/sdk-coin-polyx/test/unit/transactionBuilder/v8BatchStakingBuilder.ts diff --git a/modules/sdk-coin-polyx/src/lib/batchStakingBuilder.ts b/modules/sdk-coin-polyx/src/lib/batchStakingBuilder.ts index 948dfbf84d..a5023d068d 100644 --- a/modules/sdk-coin-polyx/src/lib/batchStakingBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/batchStakingBuilder.ts @@ -25,6 +25,9 @@ type ControllerValue = string | DecodedController; type PayeeValue = string | DecodedPayee; type AmountValue = string | number; +// [CLEANUP-V8-OLD] v7 path — builds `utility.batchAll([bond, nominate])` with the 3-arg +// `bond(controller, value, payee)` against v7 metadata. Retained as the Flipt rollback path +// alongside V8BatchStakingBuilder (v8 2-arg `bond(value, payee)`). export class BatchStakingBuilder extends PolyxBaseBuilder { // For bond operation protected _amount: string; diff --git a/modules/sdk-coin-polyx/src/lib/batchUnstakingBuilder.ts b/modules/sdk-coin-polyx/src/lib/batchUnstakingBuilder.ts index 14ce9084f5..34f7038a34 100644 --- a/modules/sdk-coin-polyx/src/lib/batchUnstakingBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/batchUnstakingBuilder.ts @@ -9,6 +9,8 @@ import { BatchArgs } from './iface'; import { BatchUnstakingTransactionSchema } from './txnSchema'; import utils from './utils'; +// [CLEANUP-V8-OLD] v7 path — v7 metadata material. Retained as the Flipt rollback path alongside +// V8BatchUnstakingBuilder (same logic, v8 material). export class BatchUnstakingBuilder extends PolyxBaseBuilder { protected _amount: string; diff --git a/modules/sdk-coin-polyx/src/lib/bondExtraBuilder.ts b/modules/sdk-coin-polyx/src/lib/bondExtraBuilder.ts index 268ae836ca..11d22cb4da 100644 --- a/modules/sdk-coin-polyx/src/lib/bondExtraBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/bondExtraBuilder.ts @@ -9,6 +9,8 @@ import utils from './utils'; import { BondExtraArgs } from './iface'; import BigNumber from 'bignumber.js'; +// [CLEANUP-V8-OLD] v7 path — v7 metadata material. Retained as the Flipt rollback path alongside +// V8BondExtraBuilder (same logic, v8 material). export class BondExtraBuilder extends PolyxBaseBuilder { protected _amount: string; diff --git a/modules/sdk-coin-polyx/src/lib/iface.ts b/modules/sdk-coin-polyx/src/lib/iface.ts index 603409f0d8..b2cb1cee8d 100644 --- a/modules/sdk-coin-polyx/src/lib/iface.ts +++ b/modules/sdk-coin-polyx/src/lib/iface.ts @@ -215,12 +215,25 @@ export interface DecodedTx extends Omit { method: TxMethod; } +// [CLEANUP-V8-OLD] v7 staking.bond args carry `controller` (bond(controller, value, payee)). +// v8 drops the controller leg (stash is its own controller) — see V8BondArgs below. Kept for the +// v7 BatchStakingBuilder rollback path. export interface BondArgs extends Args { value: string; controller: string; payee: string | { Account: string }; } +/** + * v8 staking.bond args. Polymesh v8 changed `bond(controller, value, payee)` to + * `bond(value, payee)` — the stash account is its own controller, so no `controller` field is + * encoded. Used by V8BatchStakingBuilder. + */ +export interface V8BondArgs extends Args { + value: string; + payee: string | { Account: string }; +} + export interface BondExtraArgs extends Args { maxAdditional: string; } diff --git a/modules/sdk-coin-polyx/src/lib/index.ts b/modules/sdk-coin-polyx/src/lib/index.ts index 97c7888020..8ad999b65c 100644 --- a/modules/sdk-coin-polyx/src/lib/index.ts +++ b/modules/sdk-coin-polyx/src/lib/index.ts @@ -31,11 +31,17 @@ export { V8RegisterDidBuilder } from './v8RegisterDidBuilder'; export { V8TokenTransferBuilder } from './v8TokenTransferBuilder'; export { V8HexTokenTransferBuilder } from './v8HexTokenTransferBuilder'; export { V8PreApproveAssetBuilder } from './v8PreApproveAssetBuilder'; +export { V8BatchStakingBuilder } from './v8BatchStakingBuilder'; +export { V8BondExtraBuilder } from './v8BondExtraBuilder'; +export { V8UnbondBuilder } from './v8UnbondBuilder'; +export { V8BatchUnstakingBuilder } from './v8BatchUnstakingBuilder'; +export { V8WithdrawUnbondedBuilder } from './v8WithdrawUnbondedBuilder'; +export { V8NominateBuilder } from './v8NominateBuilder'; import polyxUtils from './utils'; export { Utils, default as utils } from './utils'; export * from './iface'; -export { BondArgs, NominateArgs, BatchCallObject, BatchArgs } from './iface'; +export { BondArgs, V8BondArgs, NominateArgs, BatchCallObject, BatchArgs } from './iface'; /** * Checks if a string is a valid Polymesh DID (Decentralized Identifier) diff --git a/modules/sdk-coin-polyx/src/lib/nominateBuilder.ts b/modules/sdk-coin-polyx/src/lib/nominateBuilder.ts index 45d46b2962..26ef76b456 100644 --- a/modules/sdk-coin-polyx/src/lib/nominateBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/nominateBuilder.ts @@ -8,6 +8,8 @@ import { NominateTransactionSchema } from './txnSchema'; import utils from './utils'; import { NominateArgs } from './iface'; +// [CLEANUP-V8-OLD] v7 path — v7 metadata material. Retained as the Flipt rollback path alongside +// V8NominateBuilder (same logic, v8 material). export class NominateBuilder extends PolyxBaseBuilder { protected _validators: string[] = []; diff --git a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts index e45829874e..83049db1c1 100644 --- a/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-polyx/src/lib/transactionBuilderFactory.ts @@ -26,6 +26,12 @@ import { V8RegisterDidBuilder } from './v8RegisterDidBuilder'; import { V8TokenTransferBuilder } from './v8TokenTransferBuilder'; import { V8HexTokenTransferBuilder } from './v8HexTokenTransferBuilder'; import { V8PreApproveAssetBuilder } from './v8PreApproveAssetBuilder'; +import { V8BatchStakingBuilder } from './v8BatchStakingBuilder'; +import { V8BondExtraBuilder } from './v8BondExtraBuilder'; +import { V8UnbondBuilder } from './v8UnbondBuilder'; +import { V8BatchUnstakingBuilder } from './v8BatchUnstakingBuilder'; +import { V8WithdrawUnbondedBuilder } from './v8WithdrawUnbondedBuilder'; +import { V8NominateBuilder } from './v8NominateBuilder'; export type SupportedTransaction = BaseTransaction | PolyxTransaction; @@ -117,6 +123,30 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new V8PreApproveAssetBuilder(this._coinConfig); } + getV8BatchStakingBuilder(): V8BatchStakingBuilder { + return new V8BatchStakingBuilder(this._coinConfig); + } + + getV8BondExtraBuilder(): V8BondExtraBuilder { + return new V8BondExtraBuilder(this._coinConfig); + } + + getV8UnbondBuilder(): V8UnbondBuilder { + return new V8UnbondBuilder(this._coinConfig); + } + + getV8BatchUnstakingBuilder(): V8BatchUnstakingBuilder { + return new V8BatchUnstakingBuilder(this._coinConfig); + } + + getV8WithdrawUnbondedBuilder(): V8WithdrawUnbondedBuilder { + return new V8WithdrawUnbondedBuilder(this._coinConfig); + } + + getV8NominateBuilder(): V8NominateBuilder { + return new V8NominateBuilder(this._coinConfig); + } + getWalletInitializationBuilder(): void { throw new NotImplementedError(`walletInitialization for ${this._coinConfig.name} not implemented`); } @@ -134,10 +164,24 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { private getBuilder(rawTxn: string): TransactionBuilder { const registry = SingletonRegistry.getInstance(this._material); - const decodedTxn = decode(rawTxn, { - metadataRpc: this._material.metadata, - registry: registry, - }); + let decodedTxn; + try { + decodedTxn = decode(rawTxn, { + metadataRpc: this._material.metadata, + registry: registry, + }); + } catch (err) { + // v8-encoded transactions use different chain metadata and signed extensions, so they + // cannot be decoded with v7 material. Retry against v8 material and route to the matching + // v8 staking builder (e.g. batchAll([bond, nominate]) with the 2-arg v8 bond → no + // controller). Falls back to re-throwing the original v7 decode error if the transaction is + // not a recognized v8 staking transaction. + const v8Builder = this.tryGetV8Builder(rawTxn); + if (v8Builder) { + return v8Builder; + } + throw err; + } const methodName = decodedTxn.method?.name; if (methodName === Interface.MethodNames.TransferWithMemo) { @@ -193,4 +237,53 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { throw new Error('Transaction cannot be parsed or has an unsupported transaction type'); } + + /** + * Attempt to decode a transaction against v8 chain metadata and return the matching v8 staking + * builder. Returns undefined when the transaction cannot be decoded with v8 material or is not a + * recognized v8 staking transaction, so the caller can surface the original v7 decode error. + */ + private tryGetV8Builder(rawTxn: string): TransactionBuilder | undefined { + const v8Material = utils.getV8Material(this._coinConfig.network.type); + const registry = SingletonRegistry.getInstance(v8Material); + let decodedTxn; + try { + decodedTxn = decode(rawTxn, { + metadataRpc: v8Material.metadata, + registry: registry, + }); + } catch (e) { + return undefined; + } + + const methodName = decodedTxn.method?.name; + if (methodName === 'batchAll') { + const args = decodedTxn.method.args as { calls?: BatchCallObject[] }; + + if (args.calls && args.calls.length === 2) { + const firstCallMethod = utils.decodeMethodName(args.calls[0], registry); + const secondCallMethod = utils.decodeMethodName(args.calls[1], registry); + + // v8 batch staking pattern: bond (2-arg, no controller) + nominate + if (firstCallMethod === 'bond' && secondCallMethod === 'nominate') { + return this.getV8BatchStakingBuilder(); + } + // v8 batch unstaking pattern: chill + unbond + if (firstCallMethod === 'chill' && secondCallMethod === 'unbond') { + return this.getV8BatchUnstakingBuilder(); + } + } + return this.getV8BatchStakingBuilder(); + } else if (methodName === 'bondExtra') { + return this.getV8BondExtraBuilder(); + } else if (methodName === 'nominate') { + return this.getV8NominateBuilder(); + } else if (methodName === 'unbond') { + return this.getV8UnbondBuilder(); + } else if (methodName === 'withdrawUnbonded') { + return this.getV8WithdrawUnbondedBuilder(); + } + + return undefined; + } } diff --git a/modules/sdk-coin-polyx/src/lib/txnSchema.ts b/modules/sdk-coin-polyx/src/lib/txnSchema.ts index ba2f2bdaee..8516401650 100644 --- a/modules/sdk-coin-polyx/src/lib/txnSchema.ts +++ b/modules/sdk-coin-polyx/src/lib/txnSchema.ts @@ -18,6 +18,18 @@ export type NominateValidationObject = { validators: string[]; }; +// v8 bond/batch validation objects — no `controller` (stash is its own controller in v8). +export type V8BatchValidationObject = { + amount: string; + payee: string | { Account: string }; + validators: string[]; +}; + +export type V8BondValidationObject = { + value: string; + payee: string | { Account: string }; +}; + export type BondExtraValidationObject = { value: string; }; @@ -221,6 +233,51 @@ export const BatchTransactionSchema = { .validate(value), }; +// v8 batch validation — mirrors BatchTransactionSchema but drops the `controller` field, which +// v8 staking.bond no longer encodes. +export const V8BatchTransactionSchema = { + validate: (value: V8BatchValidationObject): joi.ValidationResult => + joi + .object({ + amount: joi.string().required(), + // Payee can be a string or an object with Account property + payee: joi + .alternatives() + .try( + joi.string(), + joi.object({ + Account: addressSchema, + }) + ) + .required(), + validators: joi.array().items(addressSchema).min(1).max(16).required(), + }) + .validate(value), + + validateBond: (value: V8BondValidationObject): joi.ValidationResult => + joi + .object({ + value: joi.string().required(), + payee: joi + .alternatives() + .try( + joi.string(), + joi.object({ + Account: addressSchema, + }) + ) + .required(), + }) + .validate(value), + + validateNominate: (value: NominateValidationObject): joi.ValidationResult => + joi + .object({ + validators: joi.array().items(addressSchema).min(1).max(16).required(), + }) + .validate(value), +}; + export const bondSchema = joi.object({ value: joi.string().required(), controller: addressSchema.required(), diff --git a/modules/sdk-coin-polyx/src/lib/unbondBuilder.ts b/modules/sdk-coin-polyx/src/lib/unbondBuilder.ts index b733a26e4c..643481bd12 100644 --- a/modules/sdk-coin-polyx/src/lib/unbondBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/unbondBuilder.ts @@ -9,6 +9,8 @@ import utils from './utils'; import { UnbondArgs } from './iface'; import BigNumber from 'bignumber.js'; +// [CLEANUP-V8-OLD] v7 path — v7 metadata material. Retained as the Flipt rollback path alongside +// V8UnbondBuilder (same logic, v8 material). export class UnbondBuilder extends PolyxBaseBuilder { protected _amount: string; diff --git a/modules/sdk-coin-polyx/src/lib/v8BatchStakingBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8BatchStakingBuilder.ts new file mode 100644 index 0000000000..626f2f740e --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8BatchStakingBuilder.ts @@ -0,0 +1,332 @@ +import { Transaction } from './transaction'; +import { PolyxBaseBuilder } from './baseBuilder'; +import { DecodedSignedTx, DecodedSigningPayload, UnsignedTransaction } from '@substrate/txwrapper-core'; +import { methods } from '@substrate/txwrapper-polkadot'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { V8BatchTransactionSchema } from './txnSchema'; +import utils from './utils'; +import { BatchArgs, NominateArgs, V8BondArgs } from './iface'; +import BigNumber from 'bignumber.js'; + +// Type definitions for decoded transaction formats +interface DecodedPayee { + staked?: null; + stash?: null; + controller?: null; + account?: string; +} + +type PayeeValue = string | DecodedPayee; +type AmountValue = string | number; + +/** + * Polymesh v8 batch staking builder. + * + * Polymesh v8 changed `staking.bond` from `bond(controller, value, payee)` to `bond(value, payee)` + * — the stash account is its own controller, so no `controller` leg is encoded. This builder + * constructs `utility.batchAll([bond, nominate])` with the 2-arg bond call against v8 chain + * metadata (v8 specVersion / txVersion). + * + * Behaviour is otherwise identical to the v7 {@link BatchStakingBuilder}, which is retained as the + * `[CLEANUP-V8-OLD]` rollback path. + */ +export class V8BatchStakingBuilder extends PolyxBaseBuilder { + // For bond operation + protected _amount: string; + protected _payee: string | { Account: string }; + + // For nominate operation + protected _validators: string[] = []; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } + + protected get transactionType(): TransactionType { + return TransactionType.Batch; + } + + /** + * Build a batch transaction that combines bond and nominate operations + * Both operations are required and always atomic (using batchAll) + */ + protected buildTransaction(): UnsignedTransaction { + // Ensure both bond and nominate operations are included + if (!this._amount || this._validators.length === 0) { + throw new InvalidTransactionError('Batch transaction must include both bond and nominate operations'); + } + + const baseTxInfo = this.createBaseTxInfo(); + + // Create the individual calls + const calls: string[] = []; + + // Add bond call — v8 bond takes only { value, payee } (no controller) + const bondCall = methods.staking.bond( + { + value: this._amount, + payee: this._payee || 'Staked', + } as unknown as { controller: string; value: string; payee: string | { Account: string } }, + baseTxInfo.baseTxInfo, + baseTxInfo.options + ); + calls.push(bondCall.method); + + // Add nominate call + const nominateCall = methods.staking.nominate( + { + targets: this._validators, + }, + baseTxInfo.baseTxInfo, + baseTxInfo.options + ); + calls.push(nominateCall.method); + + // Always use batchAll (atomic) + return methods.utility.batchAll( + { + calls, + }, + baseTxInfo.baseTxInfo, + baseTxInfo.options + ); + } + + /** + * Set the staking amount for bond + */ + amount(amount: string): this { + this.validateValue(new BigNumber(amount)); + this._amount = amount; + return this; + } + + /** + * Get the staking amount + */ + getAmount(): string { + return this._amount; + } + + /** + * Set the rewards destination for bond ('Staked', 'Stash','Controller', or { Account: string }) + */ + payee(payee: string | { Account: string }): this { + this._payee = payee; + return this; + } + + /** + * Get the payee + */ + getPayee(): string | { Account: string } { + return this._payee; + } + + /** + * Set the validators to nominate + */ + validators(validators: string[]): this { + for (const address of validators) { + this.validateAddress({ address }); + } + this._validators = validators; + return this; + } + + /** + * Get the validators to nominate + */ + getValidators(): string[] { + return this._validators; + } + + /** @inheritdoc */ + validateDecodedTransaction(decodedTxn: DecodedSigningPayload | DecodedSignedTx): void { + const methodName = decodedTxn.method?.name as string; + + // batch bond and nominate + if (methodName === 'batchAll') { + const txMethod = decodedTxn.method.args as unknown as BatchArgs; + const calls = txMethod.calls; + + if (calls.length !== 2) { + throw new InvalidTransactionError( + `Invalid batch staking transaction: expected 2 calls but got ${calls.length}` + ); + } + + // Check that first call is bond + const firstCallMethod = utils.decodeMethodName(calls[0], this._registry); + if (firstCallMethod !== 'bond') { + throw new InvalidTransactionError( + `Invalid batch staking transaction: first call should be bond but got ${firstCallMethod}` + ); + } + + // Check that second call is nominate + const secondCallMethod = utils.decodeMethodName(calls[1], this._registry); + if (secondCallMethod !== 'nominate') { + throw new InvalidTransactionError( + `Invalid batch staking transaction: second call should be nominate but got ${secondCallMethod}` + ); + } + + // Validate bond arguments + const bondArgs = calls[0].args as unknown as V8BondArgs; + this.validateBondArgs(bondArgs); + + // Validate nominate arguments + const nominateArgs = calls[1].args as unknown as NominateArgs; + this.validateNominateArgs(nominateArgs); + } else { + throw new InvalidTransactionError(`Invalid transaction type: ${methodName}`); + } + } + + /** + * Normalize a decoded payee value into the construction-side shape. + */ + private normalizePayee(payeeValue: PayeeValue): string | { Account: string } { + let normalizedPayee: string | { Account: string } = payeeValue as string; + if (typeof payeeValue === 'object' && payeeValue !== null) { + const decodedPayee = payeeValue as DecodedPayee; + if (decodedPayee.staked !== undefined) { + normalizedPayee = 'Staked'; + } else if (decodedPayee.stash !== undefined) { + normalizedPayee = 'Stash'; + } else if (decodedPayee.controller !== undefined) { + normalizedPayee = 'Controller'; + } else if (decodedPayee.account) { + normalizedPayee = { Account: decodedPayee.account }; + } + } + return normalizedPayee; + } + + /** + * Validate v8 bond arguments (no controller) + */ + private validateBondArgs(args: V8BondArgs): void { + // Handle both string and number formats for value + const amountValue = args.value as AmountValue; + const valueString = typeof amountValue === 'string' ? amountValue : amountValue.toString(); + + const normalizedPayee = this.normalizePayee(args.payee as PayeeValue); + + const validationResult = V8BatchTransactionSchema.validateBond({ + value: valueString, + payee: normalizedPayee, + }); + + if (validationResult.error) { + throw new InvalidTransactionError(`Invalid bond args: ${validationResult.error.message}`); + } + } + + /** + * Validate nominate arguments + */ + private validateNominateArgs(args: NominateArgs): void { + // Handle both string and object formats for targets + const targetAddresses = args.targets.map((target) => { + if (typeof target === 'string') { + return target; + } else if (target && typeof target === 'object' && 'id' in target) { + return (target as { id: string }).id; + } + throw new InvalidTransactionError(`Invalid target format: ${JSON.stringify(target)}`); + }); + + const validationResult = V8BatchTransactionSchema.validateNominate({ + validators: targetAddresses, + }); + + if (validationResult.error) { + throw new InvalidTransactionError(`Invalid nominate args: ${validationResult.error.message}`); + } + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + const tx = super.fromImplementation(rawTransaction); + + // Check if the transaction is a batch transaction + if ((this._method?.name as string) !== 'batchAll') { + throw new InvalidTransactionError(`Invalid Transaction Type: ${this._method?.name}. Expected batchAll`); + } + + if (this._method) { + const txMethod = this._method.args as unknown as BatchArgs; + + for (const call of txMethod.calls) { + const callMethod = utils.decodeMethodName(call, this._registry); + if (callMethod === 'bond') { + const bondArgs = call.args as unknown as V8BondArgs; + // Handle both string and number formats for value + const amountValue = bondArgs.value as AmountValue; + const valueString = typeof amountValue === 'string' ? amountValue : amountValue.toString(); + this.amount(valueString); + + // Handle different payee formats + this.payee(this.normalizePayee(bondArgs.payee as PayeeValue)); + } else if (callMethod === 'nominate') { + const nominateArgs = call.args as unknown as NominateArgs; + + // Handle both string and object formats for targets + const targetAddresses = nominateArgs.targets.map((target) => { + if (typeof target === 'string') { + return target; + } else if (target && typeof target === 'object' && 'id' in target) { + return (target as { id: string }).id; + } + throw new InvalidTransactionError(`Invalid target format: ${JSON.stringify(target)}`); + }); + this.validators(targetAddresses); + } + } + } + + return tx; + } + + /** @inheritdoc */ + validateTransaction(tx: Transaction): void { + super.validateTransaction(tx); + this.validateFields(); + } + + /** + * Validate the builder fields + */ + private validateFields(): void { + // Ensure both bond and nominate operations are included + if (!this._amount || this._validators.length === 0) { + throw new InvalidTransactionError('Batch transaction must include both bond and nominate operations'); + } + + const validationResult = V8BatchTransactionSchema.validate({ + amount: this._amount, + payee: this._payee, + validators: this._validators, + }); + + if (validationResult.error) { + throw new InvalidTransactionError(`Invalid transaction: ${validationResult.error.message}`); + } + } + + testValidateFields(): void { + this.validateFields(); + } + + public testValidateBondArgs(args: V8BondArgs): void { + return this.validateBondArgs(args); + } + + public testValidateNominateArgs(args: NominateArgs): void { + return this.validateNominateArgs(args); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/v8BatchUnstakingBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8BatchUnstakingBuilder.ts new file mode 100644 index 0000000000..57675b9e52 --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8BatchUnstakingBuilder.ts @@ -0,0 +1,15 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BatchUnstakingBuilder } from './batchUnstakingBuilder'; +import utils from './utils'; + +/** + * Builds a `utility.batchAll([chill, unbond])` unstaking transaction against Polymesh v8 chain + * metadata. chill/unbond are unchanged between v7 and v8; only the material (specVersion / + * txVersion) differs from {@link BatchUnstakingBuilder}. + */ +export class V8BatchUnstakingBuilder extends BatchUnstakingBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/v8BondExtraBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8BondExtraBuilder.ts new file mode 100644 index 0000000000..b444ca556c --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8BondExtraBuilder.ts @@ -0,0 +1,15 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BondExtraBuilder } from './bondExtraBuilder'; +import utils from './utils'; + +/** + * Builds a staking.bondExtra transaction against Polymesh v8 chain metadata. bondExtra is + * unchanged between v7 and v8 (`bondExtra(maxAdditional)`); only the material (specVersion / + * txVersion) differs from {@link BondExtraBuilder}. + */ +export class V8BondExtraBuilder extends BondExtraBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/v8NominateBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8NominateBuilder.ts new file mode 100644 index 0000000000..52b40cd306 --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8NominateBuilder.ts @@ -0,0 +1,15 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { NominateBuilder } from './nominateBuilder'; +import utils from './utils'; + +/** + * Builds a staking.nominate transaction against Polymesh v8 chain metadata. nominate is unchanged + * between v7 and v8 (`nominate(targets)`); only the material (specVersion / txVersion) differs from + * {@link NominateBuilder}. + */ +export class V8NominateBuilder extends NominateBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/v8UnbondBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8UnbondBuilder.ts new file mode 100644 index 0000000000..56855e8b0d --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8UnbondBuilder.ts @@ -0,0 +1,15 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { UnbondBuilder } from './unbondBuilder'; +import utils from './utils'; + +/** + * Builds a staking.unbond transaction against Polymesh v8 chain metadata. unbond is unchanged + * between v7 and v8 (`unbond(value)`); only the material (specVersion / txVersion) differs from + * {@link UnbondBuilder}. + */ +export class V8UnbondBuilder extends UnbondBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/v8WithdrawUnbondedBuilder.ts b/modules/sdk-coin-polyx/src/lib/v8WithdrawUnbondedBuilder.ts new file mode 100644 index 0000000000..a45891dfe4 --- /dev/null +++ b/modules/sdk-coin-polyx/src/lib/v8WithdrawUnbondedBuilder.ts @@ -0,0 +1,15 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { WithdrawUnbondedBuilder } from './withdrawUnbondedBuilder'; +import utils from './utils'; + +/** + * Builds a staking.withdrawUnbonded transaction against Polymesh v8 chain metadata. + * withdrawUnbonded is unchanged between v7 and v8 (`withdrawUnbonded(numSlashingSpans)`); only the + * material (specVersion / txVersion) differs from {@link WithdrawUnbondedBuilder}. + */ +export class V8WithdrawUnbondedBuilder extends WithdrawUnbondedBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.material(utils.getV8Material(_coinConfig.network.type)); + } +} diff --git a/modules/sdk-coin-polyx/src/lib/withdrawUnbondedBuilder.ts b/modules/sdk-coin-polyx/src/lib/withdrawUnbondedBuilder.ts index 0f72bc38bd..5b0bb65cef 100644 --- a/modules/sdk-coin-polyx/src/lib/withdrawUnbondedBuilder.ts +++ b/modules/sdk-coin-polyx/src/lib/withdrawUnbondedBuilder.ts @@ -8,6 +8,8 @@ import { PolyxBaseBuilder } from './baseBuilder'; import { WithdrawUnbondedTransactionSchema } from './txnSchema'; import { WithdrawUnbondedArgs } from './iface'; +// [CLEANUP-V8-OLD] v7 path — v7 metadata material. Retained as the Flipt rollback path alongside +// V8WithdrawUnbondedBuilder (same logic, v8 material). export class WithdrawUnbondedBuilder extends PolyxBaseBuilder { protected _slashingSpans = 0; diff --git a/modules/sdk-coin-polyx/test/resources/index.ts b/modules/sdk-coin-polyx/test/resources/index.ts index 214c880fcb..c0f17a08e1 100644 --- a/modules/sdk-coin-polyx/test/resources/index.ts +++ b/modules/sdk-coin-polyx/test/resources/index.ts @@ -136,6 +136,37 @@ export const stakingTx = { }, }; +/** v8 testnet sandbox — block 24829135, stakeV8.cjs --batch */ +export const stakingTxV8 = { + sandbox: { + stash: '5CLYvxwx4PUS678MNuhNJ9EfpUU9utrYCz9WVxovac4u9AYD', + validator: '5C7kNpSvVr22Z1X6gVAUjfahSJfSpvw4DHNoY7uUHpLfEJZR', + bondAmount: '10000000', + payee: 'Staked', + specVersion: 8000000, + transactionVersion: 8, + blockHeight: 24829135, + blockHash: '0xfe64f0f0be51fc791b12dcf81d3ad82a682719f498639dde956baaf473a57e8c', + referenceBlock: '0x52ea71e4fec68c49f8e6511c7209bc6ab5411547308a37648d126f9e0041a420', + nonce: 189, + firstValid: 24829132, + bondedEvent: ['5CLYvxwx4PUS678MNuhNJ9EfpUU9utrYCz9WVxovac4u9AYD', 10000000], + }, + batch: { + bondAndNominate: { + // full utility.batchAll method hex from sandbox (46 bytes) + callHex: '0x2902081100025a62020011050400025237fdbea82f075296416fa096d3b9807c4f8763d7c3474fdd747007379811', + // full ExtrinsicPayload.toU8a({ method: true }) — 125 bytes + extrinsicPayloadHex: + '0x2902081100025a62020011050400025237fdbea82f075296416fa096d3b9807c4f8763d7c3474fdd747007379811c500f502000000127a00080000002ace05e703aa50b48c0ccccfc8b424f7aab9a1e2c424ed12e45d20b1e8ffd0d652ea71e4fec68c49f8e6511c7209bc6ab5411547308a37648d126f9e0041a42000', + payloadLength: 125, + specVersionLE: '00127a00', + txVersionLE: '08000000', + tailAfterRefBlock: '0000', + }, + }, +}; + export const nominateTx = { unsigned: '0x11050800025237fdbea82f075296416fa096d3b9807c4f8763d7c3474fdd7470073798110060c819a103b56679947c39924a7cc616cb78e84da6c5303ebe1521b3feb62813', diff --git a/modules/sdk-coin-polyx/test/unit/transactionBuilder/v8BatchStakingBuilder.ts b/modules/sdk-coin-polyx/test/unit/transactionBuilder/v8BatchStakingBuilder.ts new file mode 100644 index 0000000000..c1a66d61c7 --- /dev/null +++ b/modules/sdk-coin-polyx/test/unit/transactionBuilder/v8BatchStakingBuilder.ts @@ -0,0 +1,236 @@ +import { decode } from '@substrate/txwrapper-polkadot'; +import { coins } from '@bitgo/statics'; +import should from 'should'; +import { + TransactionBuilderFactory, + V8BatchStakingBuilder, + V8BondExtraBuilder, + V8UnbondBuilder, + V8BatchUnstakingBuilder, + V8WithdrawUnbondedBuilder, + V8NominateBuilder, + SingletonRegistry, +} from '../../../src/lib'; +import { BatchArgs, NominateArgs, V8BondArgs } from '../../../src/lib/iface'; +import utils from '../../../src/lib/utils'; +import { testnetV8Material, mainnetV8Material } from '../../../src/resources'; +import { stakingTxV8 } from '../../resources'; +import { buildTestConfig, buildMainnetConfig } from './base'; + +describe('V8BatchStakingBuilder', function () { + const factory = new TransactionBuilderFactory(coins.get('tpolyx')); + + const { stash, validator, bondAmount, referenceBlock, nonce, firstValid } = stakingTxV8.sandbox; + + const buildSandboxBatch = () => + new V8BatchStakingBuilder(buildTestConfig()) + .amount(bondAmount) + .payee('Staked') + .validators([validator]) + .sender({ address: stash }) + .validity({ firstValid, maxDuration: 64 }) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: nonce }); + + describe('v8 material', () => { + it('testnet builder uses v8 specVersion and txVersion', () => { + const builder = new V8BatchStakingBuilder(buildTestConfig()); + const material = (builder as any)._material; + should.equal(material.specVersion, testnetV8Material.specVersion); + should.equal(material.txVersion, testnetV8Material.txVersion); + should.equal(material.specVersion, 8000000); + }); + + it('mainnet builder uses v8 specVersion and txVersion', () => { + const builder = new V8BatchStakingBuilder(buildMainnetConfig()); + const material = (builder as any)._material; + should.equal(material.specVersion, mainnetV8Material.specVersion); + should.equal(material.txVersion, mainnetV8Material.txVersion); + }); + }); + + describe('factory method', () => { + it('getV8BatchStakingBuilder returns a V8BatchStakingBuilder carrying v8 material', () => { + const builder = factory.getV8BatchStakingBuilder(); + should.ok(builder instanceof V8BatchStakingBuilder); + should.equal((builder as any)._material.specVersion, testnetV8Material.specVersion); + }); + }); + + describe('setter validation', () => { + it('should not expose a controller setter', () => { + const builder = factory.getV8BatchStakingBuilder(); + should.equal(typeof (builder as any).controller, 'undefined'); + should.equal(typeof (builder as any).getController, 'undefined'); + }); + + it('should require both bond and nominate operations', () => { + let builder = factory.getV8BatchStakingBuilder(); + builder.amount(bondAmount).payee('Staked'); + should.throws(() => builder.testValidateFields(), /must include both bond and nominate operations/); + + builder = factory.getV8BatchStakingBuilder(); + builder.validators([validator]); + should.throws(() => builder.testValidateFields(), /must include both bond and nominate operations/); + + builder = factory.getV8BatchStakingBuilder(); + builder.amount(bondAmount).payee('Staked').validators([validator]); + should.doesNotThrow(() => builder.testValidateFields()); + }); + }); + + describe('build transaction', () => { + it('builds batchAll([bond, nominate]) with v8 specVersion and no controller', async () => { + const tx = await buildSandboxBatch().build(); + const txJson = tx.toJson(); + should.equal(txJson.specVersion, 8000000); + should.equal(txJson.specVersion, testnetV8Material.specVersion); + should.equal(txJson.transactionVersion, testnetV8Material.txVersion); + }); + + it('inner bond leg + full batch method hex matches the testnet sandbox', async () => { + const tx = await buildSandboxBatch().build(); + const methodHex = (tx as any)._substrateTransaction.method as string; + // Full utility.batchAll method hex is byte-for-byte identical to the on-chain sandbox tx. + should.equal(methodHex, stakingTxV8.batch.bondAndNominate.callHex); + }); + + it('decodes with v8 material to bond({ value, payee }) — no controller — and nominate', async () => { + const tx = await buildSandboxBatch().build(); + const material = utils.getV8Material(coins.get('tpolyx').network.type); + const registry = SingletonRegistry.getInstance(material); + const decodedTx = decode(tx.toBroadcastFormat(), { + metadataRpc: material.metadata, + registry, + }); + + should.equal(decodedTx.method.name, 'batchAll'); + should.equal(decodedTx.method.pallet, 'utility'); + + const batchArgs = decodedTx.method.args as unknown as BatchArgs; + should.equal(batchArgs.calls.length, 2); + + const firstCall = batchArgs.calls[0]; + should.equal(utils.decodeMethodName(firstCall, registry), 'bond'); + const bondArgs = firstCall.args as unknown as V8BondArgs & { controller?: unknown }; + const bondValue = typeof bondArgs.value === 'string' ? bondArgs.value : (bondArgs.value as number).toString(); + should.equal(bondValue, bondAmount); + should.equal(bondArgs.controller, undefined); + + const secondCall = batchArgs.calls[1]; + should.equal(utils.decodeMethodName(secondCall, registry), 'nominate'); + const nominateArgs = secondCall.args as unknown as NominateArgs; + const targets = nominateArgs.targets.map((t) => (typeof t === 'string' ? t : (t as { id: string }).id)); + should.deepEqual(targets, [validator]); + }); + }); + + describe('validateDecodedTransaction', () => { + it('accepts a decoded v8 batch bond+nominate transaction', async () => { + const builder = buildSandboxBatch(); + const tx = await builder.build(); + const material = utils.getV8Material(coins.get('tpolyx').network.type); + const registry = SingletonRegistry.getInstance(material); + const decodedTx = decode(tx.toBroadcastFormat(), { metadataRpc: material.metadata, registry }); + should.doesNotThrow(() => builder.validateDecodedTransaction(decodedTx)); + }); + + it('validates v8 bond args without a controller field', () => { + const builder = factory.getV8BatchStakingBuilder(); + should.doesNotThrow(() => builder.testValidateBondArgs({ value: bondAmount, payee: 'Staked' } as V8BondArgs)); + }); + }); + + describe('from raw transaction', () => { + it('round-trips a v8 batch transaction through the builder', async () => { + const rawTxHex = (await buildSandboxBatch().build()).toBroadcastFormat(); + + const newBuilder = factory.getV8BatchStakingBuilder(); + newBuilder.from(rawTxHex); + + should.equal(newBuilder.getAmount(), bondAmount); + should.equal(newBuilder.getPayee(), 'Staked'); + should.deepEqual(newBuilder.getValidators(), [validator]); + }); + + it('factory.from routes a v8 batch bond+nominate transaction to V8BatchStakingBuilder', async () => { + const rawTxHex = (await buildSandboxBatch().build()).toBroadcastFormat(); + const builder = factory.from(rawTxHex); + should.ok(builder instanceof V8BatchStakingBuilder); + }); + }); +}); + +describe('V8 staking wrappers (smoke build)', function () { + const factory = new TransactionBuilderFactory(coins.get('tpolyx')); + const validator = stakingTxV8.sandbox.validator; + const sender = stakingTxV8.sandbox.stash; + const referenceBlock = stakingTxV8.sandbox.referenceBlock; + + const replay = { firstValid: 24829132, maxDuration: 64 }; + + it('V8BondExtraBuilder builds with v8 material', async () => { + const builder = factory.getV8BondExtraBuilder(); + should.ok(builder instanceof V8BondExtraBuilder); + const tx = await builder + .amount('10000000') + .sender({ address: sender }) + .validity(replay) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 189 }) + .build(); + should.equal(tx.toJson().specVersion, testnetV8Material.specVersion); + }); + + it('V8UnbondBuilder builds with v8 material', async () => { + const builder = factory.getV8UnbondBuilder(); + should.ok(builder instanceof V8UnbondBuilder); + const tx = await builder + .amount('10000000') + .sender({ address: sender }) + .validity(replay) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 189 }) + .build(); + should.equal(tx.toJson().specVersion, testnetV8Material.specVersion); + }); + + it('V8BatchUnstakingBuilder builds with v8 material', async () => { + const builder = factory.getV8BatchUnstakingBuilder(); + should.ok(builder instanceof V8BatchUnstakingBuilder); + const tx = await builder + .amount('10000000') + .sender({ address: sender }) + .validity(replay) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 189 }) + .build(); + should.equal(tx.toJson().specVersion, testnetV8Material.specVersion); + }); + + it('V8WithdrawUnbondedBuilder builds with v8 material', async () => { + const builder = factory.getV8WithdrawUnbondedBuilder(); + should.ok(builder instanceof V8WithdrawUnbondedBuilder); + const tx = await builder + .slashingSpans(0) + .sender({ address: sender }) + .validity(replay) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 189 }) + .build(); + should.equal(tx.toJson().specVersion, testnetV8Material.specVersion); + }); + + it('V8NominateBuilder builds with v8 material', async () => { + const builder = factory.getV8NominateBuilder(); + should.ok(builder instanceof V8NominateBuilder); + const tx = await builder + .validators([validator]) + .sender({ address: sender }) + .validity(replay) + .referenceBlock(referenceBlock) + .sequenceId({ name: 'Nonce', keyword: 'nonce', value: 189 }) + .build(); + should.equal(tx.toJson().specVersion, testnetV8Material.specVersion); + }); +});