From 41d53f91160f854aab0d7a1194cb79567d3bfe10 Mon Sep 17 00:00:00 2001 From: Gautam2305 Date: Wed, 18 Mar 2026 16:02:08 +0530 Subject: [PATCH] feat(sdk-coin-vet): add unstake buidlers for validators Ticket: SC-6238 --- modules/sdk-coin-vet/src/lib/constants.ts | 2 + modules/sdk-coin-vet/src/lib/index.ts | 4 + .../lib/transaction/signalExitTransaction.ts | 147 ++++++++++++++++++ .../src/lib/transaction/transaction.ts | 4 +- .../transaction/withdrawStakeTransaction.ts | 147 ++++++++++++++++++ .../transactionBuilder/signalExitBuilder.ts | 86 ++++++++++ .../withdrawStakeBuilder.ts | 86 ++++++++++ .../src/lib/transactionBuilderFactory.ts | 20 +++ modules/sdk-coin-vet/src/lib/utils.ts | 46 ++++++ .../transactionBuilder/signalExitBuilder.ts | 125 +++++++++++++++ .../withdrawStakeBuilder.ts | 128 +++++++++++++++ 11 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/signalExitTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transaction/withdrawStakeTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/signalExitBuilder.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/withdrawStakeBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/signalExitBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/withdrawStakeBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index aa26decf75..c65adbe03c 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -10,6 +10,8 @@ export const DELEGATE_CLAUSE_METHOD_ID = '0x08bbb824'; export const ADD_VALIDATION_METHOD_ID = '0xc3c4b138'; export const INCREASE_STAKE_METHOD_ID = '0x43b0de9a'; export const DECREASE_STAKE_METHOD_ID = '0x1a73ba01'; +export const SIGNAL_EXIT_METHOD_ID = '0xcb652cef'; +export const WITHDRAW_STAKE_METHOD_ID = '0xc23a5cea'; export const EXIT_DELEGATION_METHOD_ID = '0x69e79b7d'; export const BURN_NFT_METHOD_ID = '0x2e17de78'; export const TRANSFER_NFT_METHOD_ID = '0x23b872dd'; diff --git a/modules/sdk-coin-vet/src/lib/index.ts b/modules/sdk-coin-vet/src/lib/index.ts index f88611aee1..9abc11fd31 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -17,6 +17,8 @@ export { NFTTransaction } from './transaction/nftTransaction'; export { ValidatorRegistrationTransaction } from './transaction/validatorRegistrationTransaction'; export { IncreaseStakeTransaction } from './transaction/increaseStakeTransaction'; export { DecreaseStakeTransaction } from './transaction/decreaseStakeTransaction'; +export { SignalExitTransaction } from './transaction/signalExitTransaction'; +export { WithdrawStakeTransaction } from './transaction/withdrawStakeTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; @@ -31,5 +33,7 @@ export { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; export { ValidatorRegistrationBuilder } from './transactionBuilder/validatorRegistrationBuilder'; export { IncreaseStakeBuilder } from './transactionBuilder/increaseStakeBuilder'; export { DecreaseStakeBuilder } from './transactionBuilder/decreaseStakeBuilder'; +export { SignalExitBuilder } from './transactionBuilder/signalExitBuilder'; +export { WithdrawStakeBuilder } from './transactionBuilder/withdrawStakeBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Constants, Utils, Interface }; diff --git a/modules/sdk-coin-vet/src/lib/transaction/signalExitTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/signalExitTransaction.ts new file mode 100644 index 0000000000..c1dac7ead6 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/signalExitTransaction.ts @@ -0,0 +1,147 @@ +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core'; +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; +import EthereumAbi from 'ethereumjs-abi'; +import utils from '../utils'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix } from 'ethereumjs-util'; +import { ZERO_VALUE_AMOUNT } from '../constants'; + +export class SignalExitTransaction extends Transaction { + private _stakingContractAddress: string; + private _validator: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingUnvote; + } + + get validator(): string { + return this._validator; + } + + set validator(address: string) { + this._validator = address; + } + + get stakingContractAddress(): string { + return this._stakingContractAddress; + } + + set stakingContractAddress(address: string) { + this._stakingContractAddress = address; + } + + buildClauses(): void { + if (!this.stakingContractAddress) { + throw new Error('Staking contract address is not set'); + } + + if (!this.validator) { + throw new Error('Validator address is not set'); + } + + utils.validateContractAddressForValidatorRegistration(this.stakingContractAddress, this._coinConfig); + const signalExitData = this.getSignalExitClauseData(this.validator); + this._transactionData = signalExitData; + this._clauses = [ + { + to: this.stakingContractAddress, + value: ZERO_VALUE_AMOUNT, + data: signalExitData, + }, + ]; + + this._recipients = [ + { + address: this.stakingContractAddress, + amount: ZERO_VALUE_AMOUNT, + }, + ]; + } + + getSignalExitClauseData(validator: string): string { + const methodName = 'signalExit'; + const types = ['address']; + const params = [validator]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + toJson(): VetTransactionData { + return { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + data: this.transactionData, + value: ZERO_VALUE_AMOUNT, + sender: this.sender, + to: this.stakingContractAddress, + stakingContractAddress: this.stakingContractAddress, + validatorAddress: this.validator, + }; + } + + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + this.rawTransaction = signedTx; + + const body = signedTx.body; + this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0; + this.blockRef = body.blockRef || '0x0'; + this.expiration = typeof body.expiration === 'number' ? body.expiration : 64; + this.clauses = body.clauses || []; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0; + this.dependsOn = body.dependsOn || null; + this.nonce = String(body.nonce); + + if (body.clauses.length > 0) { + const clause = body.clauses[0]; + if (clause.to) { + this.stakingContractAddress = clause.to; + } + + if (clause.data) { + this.transactionData = clause.data; + const decoded = utils.decodeSignalExitData(clause.data); + this.validator = decoded.validator; + } + } + + this.recipients = body.clauses.map((clause) => ({ + address: (clause.to || '0x0').toString().toLowerCase(), + amount: new BigNumber(clause.value || 0).toString(), + })); + this.loadInputsAndOutputs(); + + if (signedTx.signature && signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + if (signedTx.signature) { + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) { + this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH)); + } + } + } catch (e) { + throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + } +} diff --git a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts index b0ba3f0f6b..b1a759be1a 100644 --- a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts @@ -396,7 +396,9 @@ export class Transaction extends BaseTransaction { this.type === TransactionType.StakingClaim || this.type === TransactionType.StakingLock || this.type === TransactionType.StakingAdd || - this.type === TransactionType.StakingDeactivate + this.type === TransactionType.StakingDeactivate || + this.type === TransactionType.StakingUnvote || + this.type === TransactionType.StakingPledge ) { transactionBody.reserved = { features: 1, // mark transaction as delegated i.e. will use gas payer diff --git a/modules/sdk-coin-vet/src/lib/transaction/withdrawStakeTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/withdrawStakeTransaction.ts new file mode 100644 index 0000000000..58f9bf8bfc --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/withdrawStakeTransaction.ts @@ -0,0 +1,147 @@ +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core'; +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; +import EthereumAbi from 'ethereumjs-abi'; +import utils from '../utils'; +import BigNumber from 'bignumber.js'; +import { addHexPrefix } from 'ethereumjs-util'; +import { ZERO_VALUE_AMOUNT } from '../constants'; + +export class WithdrawStakeTransaction extends Transaction { + private _stakingContractAddress: string; + private _validator: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingPledge; + } + + get validator(): string { + return this._validator; + } + + set validator(address: string) { + this._validator = address; + } + + get stakingContractAddress(): string { + return this._stakingContractAddress; + } + + set stakingContractAddress(address: string) { + this._stakingContractAddress = address; + } + + buildClauses(): void { + if (!this.stakingContractAddress) { + throw new Error('Staking contract address is not set'); + } + + if (!this.validator) { + throw new Error('Validator address is not set'); + } + + utils.validateContractAddressForValidatorRegistration(this.stakingContractAddress, this._coinConfig); + const withdrawStakeData = this.getWithdrawStakeClauseData(this.validator); + this._transactionData = withdrawStakeData; + this._clauses = [ + { + to: this.stakingContractAddress, + value: ZERO_VALUE_AMOUNT, + data: withdrawStakeData, + }, + ]; + + this._recipients = [ + { + address: this.stakingContractAddress, + amount: ZERO_VALUE_AMOUNT, + }, + ]; + } + + getWithdrawStakeClauseData(validator: string): string { + const methodName = 'withdrawStake'; + const types = ['address']; + const params = [validator]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + toJson(): VetTransactionData { + return { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + data: this.transactionData, + value: ZERO_VALUE_AMOUNT, + sender: this.sender, + to: this.stakingContractAddress, + stakingContractAddress: this.stakingContractAddress, + validatorAddress: this.validator, + }; + } + + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + this.rawTransaction = signedTx; + + const body = signedTx.body; + this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0; + this.blockRef = body.blockRef || '0x0'; + this.expiration = typeof body.expiration === 'number' ? body.expiration : 64; + this.clauses = body.clauses || []; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0; + this.dependsOn = body.dependsOn || null; + this.nonce = String(body.nonce); + + if (body.clauses.length > 0) { + const clause = body.clauses[0]; + if (clause.to) { + this.stakingContractAddress = clause.to; + } + + if (clause.data) { + this.transactionData = clause.data; + const decoded = utils.decodeWithdrawStakeData(clause.data); + this.validator = decoded.validator; + } + } + + this.recipients = body.clauses.map((clause) => ({ + address: (clause.to || '0x0').toString().toLowerCase(), + amount: new BigNumber(clause.value || 0).toString(), + })); + this.loadInputsAndOutputs(); + + if (signedTx.signature && signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + if (signedTx.signature) { + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) { + this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH)); + } + } + } catch (e) { + throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/signalExitBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/signalExitBuilder.ts new file mode 100644 index 0000000000..6908d3085c --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/signalExitBuilder.ts @@ -0,0 +1,86 @@ +import assert from 'assert'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionClause } from '@vechain/sdk-core'; + +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from '../transaction/transaction'; +import { SignalExitTransaction } from '../transaction/signalExitTransaction'; +import utils from '../utils'; + +export class SignalExitBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new SignalExitTransaction(_coinConfig); + } + + initBuilder(tx: SignalExitTransaction): void { + this._transaction = tx; + } + + get signalExitTransaction(): SignalExitTransaction { + return this._transaction as SignalExitTransaction; + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingUnvote; + } + + protected isValidTransactionClauses(clauses: TransactionClause[]): boolean { + try { + if (!clauses || !Array.isArray(clauses) || clauses.length === 0) { + return false; + } + + const clause = clauses[0]; + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + stakingContractAddress(address: string): this { + if (!address) { + throw new Error('Staking contract address is required'); + } + this.validateAddress({ address }); + this.signalExitTransaction.stakingContractAddress = address; + return this; + } + + validator(address: string): this { + if (!address) { + throw new Error('Validator address is required'); + } + this.validateAddress({ address }); + this.signalExitTransaction.validator = address; + return this; + } + + transactionData(data: string): this { + this.signalExitTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: SignalExitTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + assert(transaction.validator, 'Validator address is required'); + + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.signalExitTransaction.build(); + return this.transaction; + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/withdrawStakeBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/withdrawStakeBuilder.ts new file mode 100644 index 0000000000..ac960cd4cb --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/withdrawStakeBuilder.ts @@ -0,0 +1,86 @@ +import assert from 'assert'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionClause } from '@vechain/sdk-core'; + +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from '../transaction/transaction'; +import { WithdrawStakeTransaction } from '../transaction/withdrawStakeTransaction'; +import utils from '../utils'; + +export class WithdrawStakeBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new WithdrawStakeTransaction(_coinConfig); + } + + initBuilder(tx: WithdrawStakeTransaction): void { + this._transaction = tx; + } + + get withdrawStakeTransaction(): WithdrawStakeTransaction { + return this._transaction as WithdrawStakeTransaction; + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingPledge; + } + + protected isValidTransactionClauses(clauses: TransactionClause[]): boolean { + try { + if (!clauses || !Array.isArray(clauses) || clauses.length === 0) { + return false; + } + + const clause = clauses[0]; + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + stakingContractAddress(address: string): this { + if (!address) { + throw new Error('Staking contract address is required'); + } + this.validateAddress({ address }); + this.withdrawStakeTransaction.stakingContractAddress = address; + return this; + } + + validator(address: string): this { + if (!address) { + throw new Error('Validator address is required'); + } + this.validateAddress({ address }); + this.withdrawStakeTransaction.validator = address; + return this; + } + + transactionData(data: string): this { + this.withdrawStakeTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: WithdrawStakeTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + assert(transaction.validator, 'Validator address is required'); + + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.withdrawStakeTransaction.build(); + return this.transaction; + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts index ba543189f2..a7ae9d8a19 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -31,6 +31,10 @@ import { IncreaseStakeTransaction } from './transaction/increaseStakeTransaction import { IncreaseStakeBuilder } from './transactionBuilder/increaseStakeBuilder'; import { DecreaseStakeTransaction } from './transaction/decreaseStakeTransaction'; import { DecreaseStakeBuilder } from './transactionBuilder/decreaseStakeBuilder'; +import { SignalExitTransaction } from './transaction/signalExitTransaction'; +import { SignalExitBuilder } from './transactionBuilder/signalExitBuilder'; +import { WithdrawStakeTransaction } from './transaction/withdrawStakeTransaction'; +import { WithdrawStakeBuilder } from './transactionBuilder/withdrawStakeBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -99,6 +103,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const decreaseStakeTx = new DecreaseStakeTransaction(this._coinConfig); decreaseStakeTx.fromDeserializedSignedTransaction(signedTx); return this.getDecreaseStakeBuilder(decreaseStakeTx); + case TransactionType.StakingUnvote: + const signalExitTx = new SignalExitTransaction(this._coinConfig); + signalExitTx.fromDeserializedSignedTransaction(signedTx); + return this.getSignalExitBuilder(signalExitTx); + case TransactionType.StakingPledge: + const withdrawStakeTx = new WithdrawStakeTransaction(this._coinConfig); + withdrawStakeTx.fromDeserializedSignedTransaction(signedTx); + return this.getWithdrawStakeBuilder(withdrawStakeTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -144,6 +156,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new DecreaseStakeBuilder(this._coinConfig)); } + getSignalExitBuilder(tx?: SignalExitTransaction): SignalExitBuilder { + return this.initializeBuilder(tx, new SignalExitBuilder(this._coinConfig)); + } + + getWithdrawStakeBuilder(tx?: WithdrawStakeTransaction): WithdrawStakeBuilder { + return this.initializeBuilder(tx, new WithdrawStakeBuilder(this._coinConfig)); + } + getStakingActivateBuilder(tx?: StakeClauseTransaction): StakeClauseTxnBuilder { return this.initializeBuilder(tx, new StakeClauseTxnBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index b431eb2c4a..448961d795 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -30,6 +30,8 @@ import { ADD_VALIDATION_METHOD_ID, INCREASE_STAKE_METHOD_ID, DECREASE_STAKE_METHOD_ID, + SIGNAL_EXIT_METHOD_ID, + WITHDRAW_STAKE_METHOD_ID, } from './constants'; import { KeyPair } from './keyPair'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; @@ -110,6 +112,10 @@ export class Utils implements BaseUtils { return TransactionType.StakingAdd; } else if (clauses[0].data.startsWith(DECREASE_STAKE_METHOD_ID)) { return TransactionType.StakingDeactivate; + } else if (clauses[0].data.startsWith(SIGNAL_EXIT_METHOD_ID)) { + return TransactionType.StakingUnvote; + } else if (clauses[0].data.startsWith(WITHDRAW_STAKE_METHOD_ID)) { + return TransactionType.StakingPledge; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { return TransactionType.StakingWithdraw; } else if ( @@ -332,6 +338,46 @@ export class Utils implements BaseUtils { } } + /** + * Decodes signalExit transaction data to extract validator address + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing validator address + */ + decodeSignalExitData(data: string): { validator: string } { + try { + const parameters = data.slice(10); + + const decoded = EthereumAbi.rawDecode(['address'], Buffer.from(parameters, 'hex')); + + return { + validator: addHexPrefix(decoded[0].toString()).toLowerCase(), + }; + } catch (error) { + throw new Error(`Failed to decode signal exit data: ${error.message}`); + } + } + + /** + * Decodes withdrawStake transaction data to extract validator address + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing validator address + */ + decodeWithdrawStakeData(data: string): { validator: string } { + try { + const parameters = data.slice(10); + + const decoded = EthereumAbi.rawDecode(['address'], Buffer.from(parameters, 'hex')); + + return { + validator: addHexPrefix(decoded[0].toString()).toLowerCase(), + }; + } catch (error) { + throw new Error(`Failed to decode withdraw stake data: ${error.message}`); + } + } + /** * Decodes exit delegation transaction data to extract tokenId * diff --git a/modules/sdk-coin-vet/test/transactionBuilder/signalExitBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/signalExitBuilder.ts new file mode 100644 index 0000000000..8381916969 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/signalExitBuilder.ts @@ -0,0 +1,125 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, SignalExitTransaction } from '../../src/lib'; +import should from 'should'; +import { SIGNAL_EXIT_METHOD_ID, VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET } from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import utils from '../../src/lib/utils'; + +describe('VET Signal Exit Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const validatorAddress = '0x9a7aFCACc88c106f3bbD6B213CD0821D9224d945'; + + const createBasicTxBuilder = () => { + const txBuilder = factory.getSignalExitBuilder(); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.chainTag(0x27); + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + return txBuilder; + }; + + it('should build a signal exit transaction', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + should.exist(tx); + tx.should.be.instanceof(Transaction); + tx.should.be.instanceof(SignalExitTransaction); + + const signalExitTx = tx as SignalExitTransaction; + signalExitTx.stakingContractAddress.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + signalExitTx.validator.should.equal(validatorAddress); + + signalExitTx.clauses.length.should.equal(1); + should.exist(signalExitTx.clauses[0].to); + signalExitTx.clauses[0].to?.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + signalExitTx.clauses[0].value?.should.equal('0'); + + should.exist(signalExitTx.clauses[0].data); + const txData = signalExitTx.clauses[0].data; + txData.should.startWith(SIGNAL_EXIT_METHOD_ID); + + const methodName = 'signalExit'; + const types = ['address']; + const params = [validatorAddress]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + const expectedData = '0x' + Buffer.concat([method, args]).toString('hex'); + + txData.should.equal(expectedData); + + signalExitTx.recipients.length.should.equal(1); + signalExitTx.recipients[0].address.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + signalExitTx.recipients[0].amount.should.equal('0'); + }); + + it('should produce the correct method ID 0xcb652cef', function () { + const methodName = 'signalExit'; + const types = ['address']; + const method = EthereumAbi.methodID(methodName, types); + const methodId = '0x' + method.toString('hex'); + methodId.should.equal('0xcb652cef'); + }); + + it('should serialize and deserialize round-trip', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + const serialized = tx.toBroadcastFormat(); + + const txBuilder2 = factory.from(serialized); + const tx2 = txBuilder2.transaction as SignalExitTransaction; + tx2.should.be.instanceof(SignalExitTransaction); + tx2.stakingContractAddress + .toLowerCase() + .should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET.toLowerCase()); + tx2.validator.should.equal(validatorAddress.toLowerCase()); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.validator(validatorAddress); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when validator address is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + + await txBuilder.build().should.be.rejectedWith('Validator address is required'); + }); + + it('should throw error when stakingContractAddress is invalid', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.stakingContractAddress('invalid-address'); + }).throw(/Invalid address/); + }); + }); + + describe('decodeSignalExitData', function () { + it('should correctly decode signal exit transaction data', function () { + const methodName = 'signalExit'; + const types = ['address']; + const params = [validatorAddress]; + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + const encodedData = '0x' + Buffer.concat([method, args]).toString('hex'); + + const decodedData = utils.decodeSignalExitData(encodedData); + decodedData.validator.should.equal(validatorAddress.toLowerCase()); + decodedData.validator.should.startWith('0x'); + }); + }); +}); diff --git a/modules/sdk-coin-vet/test/transactionBuilder/withdrawStakeBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/withdrawStakeBuilder.ts new file mode 100644 index 0000000000..17af3ff0f8 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/withdrawStakeBuilder.ts @@ -0,0 +1,128 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, WithdrawStakeTransaction } from '../../src/lib'; +import should from 'should'; +import { + WITHDRAW_STAKE_METHOD_ID, + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET, +} from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import utils from '../../src/lib/utils'; + +describe('VET Withdraw Stake Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const validatorAddress = '0x9a7aFCACc88c106f3bbD6B213CD0821D9224d945'; + + const createBasicTxBuilder = () => { + const txBuilder = factory.getWithdrawStakeBuilder(); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.chainTag(0x27); + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + return txBuilder; + }; + + it('should build a withdraw stake transaction', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + should.exist(tx); + tx.should.be.instanceof(Transaction); + tx.should.be.instanceof(WithdrawStakeTransaction); + + const withdrawStakeTx = tx as WithdrawStakeTransaction; + withdrawStakeTx.stakingContractAddress.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + withdrawStakeTx.validator.should.equal(validatorAddress); + + withdrawStakeTx.clauses.length.should.equal(1); + should.exist(withdrawStakeTx.clauses[0].to); + withdrawStakeTx.clauses[0].to?.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + withdrawStakeTx.clauses[0].value?.should.equal('0'); + + should.exist(withdrawStakeTx.clauses[0].data); + const txData = withdrawStakeTx.clauses[0].data; + txData.should.startWith(WITHDRAW_STAKE_METHOD_ID); + + const methodName = 'withdrawStake'; + const types = ['address']; + const params = [validatorAddress]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + const expectedData = '0x' + Buffer.concat([method, args]).toString('hex'); + + txData.should.equal(expectedData); + + withdrawStakeTx.recipients.length.should.equal(1); + withdrawStakeTx.recipients[0].address.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + withdrawStakeTx.recipients[0].amount.should.equal('0'); + }); + + it('should produce the correct method ID 0xc23a5cea', function () { + const methodName = 'withdrawStake'; + const types = ['address']; + const method = EthereumAbi.methodID(methodName, types); + const methodId = '0x' + method.toString('hex'); + methodId.should.equal('0xc23a5cea'); + }); + + it('should serialize and deserialize round-trip', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + const serialized = tx.toBroadcastFormat(); + + const txBuilder2 = factory.from(serialized); + const tx2 = txBuilder2.transaction as WithdrawStakeTransaction; + tx2.should.be.instanceof(WithdrawStakeTransaction); + tx2.stakingContractAddress + .toLowerCase() + .should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET.toLowerCase()); + tx2.validator.should.equal(validatorAddress.toLowerCase()); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.validator(validatorAddress); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when validator address is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + + await txBuilder.build().should.be.rejectedWith('Validator address is required'); + }); + + it('should throw error when stakingContractAddress is invalid', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.stakingContractAddress('invalid-address'); + }).throw(/Invalid address/); + }); + }); + + describe('decodeWithdrawStakeData', function () { + it('should correctly decode withdraw stake transaction data', function () { + const methodName = 'withdrawStake'; + const types = ['address']; + const params = [validatorAddress]; + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + const encodedData = '0x' + Buffer.concat([method, args]).toString('hex'); + + const decodedData = utils.decodeWithdrawStakeData(encodedData); + decodedData.validator.should.equal(validatorAddress.toLowerCase()); + decodedData.validator.should.startWith('0x'); + }); + }); +});