From 1e41d77b90d8287396debf757bf5f3c748691c84 Mon Sep 17 00:00:00 2001 From: Gautam2305 Date: Tue, 3 Mar 2026 13:07:22 +0530 Subject: [PATCH] feat(sdk-coin-vet): add increaseStake builder for staking Ticket: SC-5966 --- modules/sdk-coin-vet/src/lib/constants.ts | 1 + modules/sdk-coin-vet/src/lib/index.ts | 2 + .../transaction/increaseStakeTransaction.ts | 178 +++++++++++++++ .../src/lib/transaction/transaction.ts | 3 +- .../increaseStakeBuilder.ts | 149 +++++++++++++ .../src/lib/transactionBuilderFactory.ts | 10 + modules/sdk-coin-vet/src/lib/utils.ts | 24 ++ .../increaseStakeBuilder.ts | 206 ++++++++++++++++++ 8 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/increaseStakeTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/increaseStakeBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/increaseStakeBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index d83579cc88..4a94bbbe99 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -8,6 +8,7 @@ export const STAKING_METHOD_ID = '0xd8da3bbf'; export const STAKE_CLAUSE_METHOD_ID = '0x604f2177'; export const DELEGATE_CLAUSE_METHOD_ID = '0x08bbb824'; export const ADD_VALIDATION_METHOD_ID = '0xc3c4b138'; +export const INCREASE_STAKE_METHOD_ID = '0x43b0de9a'; 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 6a61e5e74f..5894b08da2 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -15,6 +15,7 @@ export { BurnNftTransaction } from './transaction/burnNftTransaction'; export { ClaimRewardsTransaction } from './transaction/claimRewards'; export { NFTTransaction } from './transaction/nftTransaction'; export { ValidatorRegistrationTransaction } from './transaction/validatorRegistrationTransaction'; +export { IncreaseStakeTransaction } from './transaction/increaseStakeTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; @@ -27,5 +28,6 @@ export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; export { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; export { ValidatorRegistrationBuilder } from './transactionBuilder/validatorRegistrationBuilder'; +export { IncreaseStakeBuilder } from './transactionBuilder/increaseStakeBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Constants, Utils, Interface }; diff --git a/modules/sdk-coin-vet/src/lib/transaction/increaseStakeTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/increaseStakeTransaction.ts new file mode 100644 index 0000000000..c9ed072146 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/increaseStakeTransaction.ts @@ -0,0 +1,178 @@ +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'; + +export class IncreaseStakeTransaction extends Transaction { + private _stakingContractAddress: string; + private _validator: string; + private _amountToStake: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingAdd; + } + + get validator(): string { + return this._validator; + } + + set validator(address: string) { + this._validator = address; + } + + get amountToStake(): string { + return this._amountToStake; + } + + set amountToStake(amount: string) { + this._amountToStake = amount; + } + + 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 increaseStakeData = this.getIncreaseStakeClauseData(this.validator); + this._transactionData = increaseStakeData; + // Create the clause for increase stake + this._clauses = [ + { + to: this.stakingContractAddress, + value: this.amountToStake, + data: increaseStakeData, + }, + ]; + + // Set recipients based on the clauses + this._recipients = [ + { + address: this.stakingContractAddress, + amount: this.amountToStake, + }, + ]; + } + + /** + * Encodes increaseStake transaction data using ethereumjs-abi for increaseStake method + * @param {string} validator - address of the validator + * @returns {string} - The encoded transaction data + */ + getIncreaseStakeClauseData(validator: string): string { + const methodName = 'increaseStake'; + 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 { + const json: VetTransactionData = { + 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: this.amountToStake, + sender: this.sender, + to: this.stakingContractAddress, + stakingContractAddress: this.stakingContractAddress, + amountToStake: this.amountToStake, + validatorAddress: this.validator, + }; + + return json; + } + + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + // Store the raw transaction + this.rawTransaction = signedTx; + + // Set transaction body properties + 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); + + // Set increase stake-specific properties + if (body.clauses.length > 0) { + const increaseStakeClause = body.clauses[0]; + if (increaseStakeClause.to) { + this.stakingContractAddress = increaseStakeClause.to; + } + + if (increaseStakeClause.value) { + this.amountToStake = new BigNumber(increaseStakeClause.value).toFixed(); + } + + // Extract validator from increaseStake data + if (increaseStakeClause.data) { + this.transactionData = increaseStakeClause.data; + const decoded = utils.decodeIncreaseStakeData(increaseStakeClause.data); + this.validator = decoded.validator; + } + } + + // Set recipients from clauses + this.recipients = body.clauses.map((clause) => ({ + address: (clause.to || '0x0').toString().toLowerCase(), + amount: new BigNumber(clause.value || 0).toString(), + })); + this.loadInputsAndOutputs(); + + // Set sender address + if (signedTx.signature && signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + // Set signatures if present + if (signedTx.signature) { + // First signature is sender's signature + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + // If there's additional signature data, it's the fee payer's signature + 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 b6d56e0600..17b79bf828 100644 --- a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts @@ -394,7 +394,8 @@ export class Transaction extends BaseTransaction { this.type === TransactionType.StakingUnlock || this.type === TransactionType.StakingWithdraw || this.type === TransactionType.StakingClaim || - this.type === TransactionType.StakingLock + this.type === TransactionType.StakingLock || + this.type === TransactionType.StakingAdd ) { transactionBody.reserved = { features: 1, // mark transaction as delegated i.e. will use gas payer diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/increaseStakeBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/increaseStakeBuilder.ts new file mode 100644 index 0000000000..df1158d2e7 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/increaseStakeBuilder.ts @@ -0,0 +1,149 @@ +import assert from 'assert'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionClause } from '@vechain/sdk-core'; +import BigNumber from 'bignumber.js'; + +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from '../transaction/transaction'; +import { IncreaseStakeTransaction } from '../transaction/increaseStakeTransaction'; +import utils from '../utils'; + +export class IncreaseStakeBuilder extends TransactionBuilder { + /** + * Creates a new increase stake txn instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new IncreaseStakeTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing increase stake txn. + * + * @param {IncreaseStakeTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: IncreaseStakeTransaction): void { + this._transaction = tx; + } + + /** + * Gets the increase stake transaction instance. + * + * @returns {IncreaseStakeTransaction} The increase stake transaction + */ + get increaseStakeTransaction(): IncreaseStakeTransaction { + return this._transaction as IncreaseStakeTransaction; + } + + /** + * Gets the transaction type for increase stake. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingAdd; + } + + /** + * Validates the transaction clauses for increase stake transaction. + * @param {TransactionClause[]} clauses - The transaction clauses to validate. + * @returns {boolean} - Returns true if the clauses are valid, false otherwise. + */ + 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; + } + } + + /** + * Sets the staking contract address for this increase stake tx. + * The address must be explicitly provided to ensure the correct contract is used. + * + * @param {string} address - The contract address (required) + * @returns {IncreaseStakeBuilder} This transaction builder + * @throws {Error} If no address is provided + */ + stakingContractAddress(address: string): this { + if (!address) { + throw new Error('Staking contract address is required'); + } + this.validateAddress({ address }); + this.increaseStakeTransaction.stakingContractAddress = address; + return this; + } + + /** + * Sets the amount to stake for this increase stake tx (VET amount being sent). + * + * @param {string} amount - The amount to stake in wei + * @returns {IncreaseStakeBuilder} This transaction builder + */ + amountToStake(amount: string): this { + this.increaseStakeTransaction.amountToStake = amount; + return this; + } + + /** + * Sets the validator address for this increase stake tx. + * @param {string} address - The validator address + * @returns {IncreaseStakeBuilder} This transaction builder + */ + validator(address: string): this { + if (!address) { + throw new Error('Validator address is required'); + } + this.validateAddress({ address }); + this.increaseStakeTransaction.validator = address; + return this; + } + + /** + * Sets the transaction data for this increase stake tx. + * + * @param {string} data - The transaction data + * @returns {IncreaseStakeBuilder} This transaction builder + */ + transactionData(data: string): this { + this.increaseStakeTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: IncreaseStakeTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + assert(transaction.validator, 'Validator address is required'); + assert(transaction.amountToStake, 'Staking amount is required'); + + // Validate staking amount is greater than 0 + const amount = new BigNumber(transaction.amountToStake); + if (amount.isLessThanOrEqualTo(0)) { + throw new Error('Staking amount must be greater than 0'); + } + + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.increaseStakeTransaction.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 f21bcfb347..87c9e3e9a4 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -27,6 +27,8 @@ import { DelegateTxnBuilder } from './transactionBuilder/delegateTxnBuilder'; import { DelegateClauseTransaction } from './transaction/delegateClauseTransaction'; import { ValidatorRegistrationTransaction } from './transaction/validatorRegistrationTransaction'; import { ValidatorRegistrationBuilder } from './transactionBuilder/validatorRegistrationBuilder'; +import { IncreaseStakeTransaction } from './transaction/increaseStakeTransaction'; +import { IncreaseStakeBuilder } from './transactionBuilder/increaseStakeBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -87,6 +89,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const validatorRegistrationTx = new ValidatorRegistrationTransaction(this._coinConfig); validatorRegistrationTx.fromDeserializedSignedTransaction(signedTx); return this.getValidatorRegistrationBuilder(validatorRegistrationTx); + case TransactionType.StakingAdd: + const increaseStakeTx = new IncreaseStakeTransaction(this._coinConfig); + increaseStakeTx.fromDeserializedSignedTransaction(signedTx); + return this.getIncreaseStakeBuilder(increaseStakeTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -124,6 +130,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new ValidatorRegistrationBuilder(this._coinConfig)); } + getIncreaseStakeBuilder(tx?: IncreaseStakeTransaction): IncreaseStakeBuilder { + return this.initializeBuilder(tx, new IncreaseStakeBuilder(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 cd3bcfc4e8..2d4d019df4 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -28,6 +28,7 @@ import { VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_MAINNET, VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET, ADD_VALIDATION_METHOD_ID, + INCREASE_STAKE_METHOD_ID, } from './constants'; import { KeyPair } from './keyPair'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; @@ -104,6 +105,8 @@ export class Utils implements BaseUtils { return TransactionType.StakingUnlock; } else if (clauses[0].data.startsWith(ADD_VALIDATION_METHOD_ID)) { return TransactionType.StakingLock; + } else if (clauses[0].data.startsWith(INCREASE_STAKE_METHOD_ID)) { + return TransactionType.StakingAdd; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { return TransactionType.StakingWithdraw; } else if ( @@ -284,6 +287,27 @@ export class Utils implements BaseUtils { } } + /** + * Decodes increase stake transaction data to extract validator address + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing validator address + */ + decodeIncreaseStakeData(data: string): { validator: string } { + try { + const parameters = data.slice(10); + + // Decode using ethereumjs-abi directly + const decoded = EthereumAbi.rawDecode(['address'], Buffer.from(parameters, 'hex')); + + return { + validator: addHexPrefix(decoded[0].toString()).toLowerCase(), + }; + } catch (error) { + throw new Error(`Failed to decode increase stake data: ${error.message}`); + } + } + /** * Decodes exit delegation transaction data to extract tokenId * diff --git a/modules/sdk-coin-vet/test/transactionBuilder/increaseStakeBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/increaseStakeBuilder.ts new file mode 100644 index 0000000000..e4d6c47bde --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/increaseStakeBuilder.ts @@ -0,0 +1,206 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, IncreaseStakeTransaction } from '../../src/lib'; +import should from 'should'; +import { + INCREASE_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 Increase Stake Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const validatorAddress = '0x9a7aFCACc88c106f3bbD6B213CD0821D9224d945'; + const amountToStake = '1000000000000000000000000'; // 1000000 VET + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getIncreaseStakeBuilder(); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.chainTag(0x27); // Testnet chain tag + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + return txBuilder; + }; + + it('should build an increase stake transaction', async function () { + const txBuilder = factory.getIncreaseStakeBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.amountToStake(amountToStake); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.chainTag(0x27); // Testnet chain tag + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + should.exist(tx); + tx.should.be.instanceof(Transaction); + tx.should.be.instanceof(IncreaseStakeTransaction); + + const increaseStakeTransaction = tx as IncreaseStakeTransaction; + increaseStakeTransaction.stakingContractAddress.should.equal( + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET + ); + increaseStakeTransaction.validator.should.equal(validatorAddress); + increaseStakeTransaction.amountToStake.should.equal(amountToStake); + + // Verify clauses + increaseStakeTransaction.clauses.length.should.equal(1); + should.exist(increaseStakeTransaction.clauses[0].to); + should.exist(increaseStakeTransaction.clauses[0].value); + increaseStakeTransaction.clauses[0].to?.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + increaseStakeTransaction.clauses[0].value?.should.equal(amountToStake); + + // Verify transaction data is correctly encoded using ethereumABI + should.exist(increaseStakeTransaction.clauses[0].data); + const txData = increaseStakeTransaction.clauses[0].data; + txData.should.startWith(INCREASE_STAKE_METHOD_ID); + + // Verify the encoded data matches what we expect from ethereumABI + const methodName = 'increaseStake'; + 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); + + // Verify recipients + increaseStakeTransaction.recipients.length.should.equal(1); + increaseStakeTransaction.recipients[0].address.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + }); + + it('should produce the correct method ID 0x43b0de9a', function () { + const methodName = 'increaseStake'; + const types = ['address']; + const method = EthereumAbi.methodID(methodName, types); + const methodId = '0x' + method.toString('hex'); + methodId.should.equal('0x43b0de9a'); + }); + + it('should serialize and deserialize round-trip', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.amountToStake(amountToStake); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + const serialized = tx.toBroadcastFormat(); + + // Deserialize using factory.from() + const txBuilder2 = factory.from(serialized); + const tx2 = txBuilder2.transaction as IncreaseStakeTransaction; + tx2.should.be.instanceof(IncreaseStakeTransaction); + tx2.stakingContractAddress.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + tx2.validator.should.equal(validatorAddress.toLowerCase()); + tx2.amountToStake.should.equal(amountToStake); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.validator(validatorAddress); + txBuilder.amountToStake(amountToStake); + + 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); + txBuilder.amountToStake(amountToStake); + + await txBuilder.build().should.be.rejectedWith('Validator address is required'); + }); + + it('should throw error when amount is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.validator(validatorAddress); + + await txBuilder.build().should.be.rejectedWith('Staking amount is required'); + }); + + it('should throw error when stakingContractAddress is invalid', async function () { + const txBuilder = createBasicTxBuilder(); + + // Invalid address (wrong format) + should(() => { + txBuilder.stakingContractAddress('invalid-address'); + }).throw(/Invalid address/); + }); + + it('should build transaction with undefined sender but include it in inputs', async function () { + const txBuilder = factory.getIncreaseStakeBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.amountToStake(amountToStake); + txBuilder.chainTag(0x27); + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + txBuilder.validator(validatorAddress); + // Not setting sender + + const tx = await txBuilder.build(); + tx.should.be.instanceof(IncreaseStakeTransaction); + + const increaseStakeTransaction = tx as IncreaseStakeTransaction; + // Verify the transaction has inputs but with undefined address + increaseStakeTransaction.inputs.length.should.equal(1); + should.not.exist(increaseStakeTransaction.inputs[0].address); + + // Verify the transaction has the correct output + increaseStakeTransaction.outputs.length.should.equal(1); + increaseStakeTransaction.outputs[0].address.should.equal(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + }); + + it('should use network default chainTag when not explicitly set', async function () { + const txBuilder = factory.getIncreaseStakeBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + // Not setting chainTag + txBuilder.blockRef('0x0000000000000000'); + txBuilder.amountToStake(amountToStake); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.validator(validatorAddress); + + const tx = await txBuilder.build(); + tx.should.be.instanceof(IncreaseStakeTransaction); + + const increaseStakeTransaction = tx as IncreaseStakeTransaction; + // Verify the chainTag is set to the testnet default (39) + increaseStakeTransaction.chainTag.should.equal(39); + }); + }); + + describe('decodeIncreaseStakeData', function () { + it('should correctly decode increase stake transaction data with proper address formatting', function () { + // Encode a known value first + const methodName = 'increaseStake'; + 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.decodeIncreaseStakeData(encodedData); + decodedData.validator.should.equal(validatorAddress.toLowerCase()); + decodedData.validator.should.startWith('0x'); + decodedData.validator.should.equal(decodedData.validator.toLowerCase()); + }); + }); +});