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); + }); +});