From f170b755647679917776891fbe395e8bbd109d4a Mon Sep 17 00:00:00 2001 From: Vijay Jagannathan Date: Tue, 28 Oct 2025 00:24:11 +0530 Subject: [PATCH] feat(sdk-coin-vet): add support for stake txn builder Ticket: SC-3642 --- modules/sdk-coin-vet/src/lib/constants.ts | 1 + modules/sdk-coin-vet/src/lib/index.ts | 2 + .../lib/transaction/stakeClauseTransaction.ts | 181 +++++++++++++ .../src/lib/transaction/transaction.ts | 1 + .../stakeClauseTxnBuilder.ts | 157 +++++++++++ .../src/lib/transactionBuilderFactory.ts | 10 + modules/sdk-coin-vet/src/lib/utils.ts | 24 ++ .../stakeClauseTransactionBuilder.ts | 254 ++++++++++++++++++ 8 files changed, 630 insertions(+) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/stakeClauseTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/stakeClauseTxnBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/stakeClauseTransactionBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index 9e88838078..2978caf9dd 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -4,6 +4,7 @@ export const VET_BLOCK_ID_LENGTH = 64; export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb'; export const STAKING_METHOD_ID = '0xd8da3bbf'; +export const STAKE_CLAUSE_METHOD_ID = '0x604f2177'; 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 6c7b73aec6..276a42af28 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -8,6 +8,7 @@ export { AddressInitializationTransaction } from './transaction/addressInitializ export { FlushTokenTransaction } from './transaction/flushTokenTransaction'; export { TokenTransaction } from './transaction/tokenTransaction'; export { StakingTransaction } from './transaction/stakingTransaction'; +export { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; export { ExitDelegationTransaction } from './transaction/exitDelegation'; export { BurnNftTransaction } from './transaction/burnNftTransaction'; export { ClaimRewardsTransaction } from './transaction/claimRewards'; @@ -17,6 +18,7 @@ export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; export { StakingBuilder } from './transactionBuilder/stakingBuilder'; +export { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder'; export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; diff --git a/modules/sdk-coin-vet/src/lib/transaction/stakeClauseTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/stakeClauseTransaction.ts new file mode 100644 index 0000000000..4fb2aafca1 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/stakeClauseTransaction.ts @@ -0,0 +1,181 @@ +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 StakeClauseTransaction extends Transaction { + private _stakingContractAddress: string; + private _levelId: number; + private _amountToStake: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingActivate; + } + + get stakingContractAddress(): string { + return this._stakingContractAddress; + } + + set stakingContractAddress(address: string) { + this._stakingContractAddress = address; + } + + get levelId(): number { + return this._levelId; + } + + set levelId(levelId: number) { + this._levelId = levelId; + } + + get amountToStake(): string { + return this._amountToStake; + } + + set amountToStake(amount: string) { + this._amountToStake = amount; + } + + buildClauses(): void { + if (!this.stakingContractAddress) { + throw new Error('Staking contract address is not set'); + } + + utils.validateStakingContractAddress(this.stakingContractAddress, this._coinConfig); + + if (this.levelId === undefined || this.levelId === null) { + throw new Error('Level ID is not set'); + } + + if (!this.amountToStake) { + throw new Error('Amount to stake is not set'); + } + + const data = this.getStakingData(this.levelId); + this._transactionData = data; + + // Create the clause for staking + this._clauses = [ + { + to: this.stakingContractAddress, + value: this.amountToStake, + data: this._transactionData, + }, + ]; + + // Set recipients based on the clauses + this._recipients = [ + { + address: this.stakingContractAddress, + amount: this.amountToStake, + }, + ]; + } + /** + * Encodes staking transaction data using ethereumjs-abi for stake method + * + * @param {number} levelId - The level ID for staking + * @returns {string} - The encoded transaction data + */ + getStakingData(levelId: number): string { + const methodName = 'stake'; + const types = ['uint8']; + const params = [levelId]; + + 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, + nftTokenId: this.levelId, + }; + + 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 staking-specific properties + if (body.clauses.length > 0) { + const clause = body.clauses[0]; + if (clause.to) { + this.stakingContractAddress = clause.to; + } + if (clause.value) { + this.amountToStake = String(clause.value); + } + if (clause.data) { + this.transactionData = clause.data; + const decoded = utils.decodeStakeClauseData(clause.data); + this.levelId = decoded.levelId; + } + } + + // 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 34860fee9f..5a8fc6677e 100644 --- a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts @@ -389,6 +389,7 @@ export class Transaction extends BaseTransaction { this.type === TransactionType.SendToken || this.type === TransactionType.SendNFT || this.type === TransactionType.ContractCall || + this.type === TransactionType.StakingActivate || this.type === TransactionType.StakingUnlock || this.type === TransactionType.StakingWithdraw || this.type === TransactionType.StakingClaim diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/stakeClauseTxnBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/stakeClauseTxnBuilder.ts new file mode 100644 index 0000000000..a45e12371e --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/stakeClauseTxnBuilder.ts @@ -0,0 +1,157 @@ +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 { StakeClauseTransaction } from '../transaction/stakeClauseTransaction'; +import utils from '../utils'; + +export class StakeClauseTxnBuilder extends TransactionBuilder { + /** + * Creates a new StakingBuilder instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new StakeClauseTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing StakingTransaction. + * + * @param {StakingTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: StakeClauseTransaction): void { + this._transaction = tx; + } + + /** + * Gets the staking transaction instance. + * + * @returns {StakingTransaction} The staking transaction + */ + get stakingTransaction(): StakeClauseTransaction { + return this._transaction as StakeClauseTransaction; + } + + /** + * Gets the transaction type for staking. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingActivate; + } + + /** + * Validates the transaction clauses for staking 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; + } + + // For staking transactions, value must be greater than 0 + if (!clause.value || clause.value === '0x0' || clause.value === '0') { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + /** + * Sets the staking contract address for this staking tx. + * The address must be explicitly provided to ensure the correct contract is used. + * + * @param {string} address - The staking contract address (required) + * @returns {StakingBuilder} 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.stakingTransaction.stakingContractAddress = address; + return this; + } + + /** + * Sets the level ID for this staking tx. + * + * @param {number} levelId - The level ID for staking + * @returns {StakingBuilder} This transaction builder + */ + levelId(levelId: number): this { + this.stakingTransaction.levelId = levelId; + return this; + } + + /** + * Sets the amount to stake for this staking tx (VET amount being sent). + * + * @param {string} amount - The amount to stake in wei + * @returns {StakingBuilder} This transaction builder + */ + amountToStake(amount: string): this { + this.stakingTransaction.amountToStake = amount; + return this; + } + + /** + * Sets the transaction data for this staking tx. + * + * @param {string} data - The transaction data + * @returns {StakingBuilder} This transaction builder + */ + transactionData(data: string): this { + this.stakingTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: StakeClauseTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + assert(transaction.amountToStake, 'Amount to stake is required'); + + // Validate amount is a valid number string + if (transaction.amountToStake) { + try { + const bn = new (require('bignumber.js'))(transaction.amountToStake); + if (!bn.isFinite() || bn.isNaN()) { + throw new Error('Invalid character'); + } + } catch (e) { + throw new Error('Invalid character'); + } + } + + assert(transaction.levelId, 'Level ID is required'); + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.stakingTransaction.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 84d3c22b54..0dec5e5e74 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -21,6 +21,8 @@ import { StakingBuilder } from './transactionBuilder/stakingBuilder'; import { StakingTransaction } from './transaction/stakingTransaction'; import { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder'; import { NFTTransaction } from './transaction/nftTransaction'; +import { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; +import { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -57,6 +59,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const stakingTx = new StakingTransaction(this._coinConfig); stakingTx.fromDeserializedSignedTransaction(signedTx); return this.getStakingBuilder(stakingTx); + case TransactionType.StakingActivate: + const stakeClauseTx = new StakeClauseTransaction(this._coinConfig); + stakeClauseTx.fromDeserializedSignedTransaction(signedTx); + return this.getStakingActivateBuilder(stakeClauseTx); case TransactionType.StakingUnlock: const exitDelegationTx = new ExitDelegationTransaction(this._coinConfig); exitDelegationTx.fromDeserializedSignedTransaction(signedTx); @@ -98,6 +104,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new StakingBuilder(this._coinConfig)); } + getStakingActivateBuilder(tx?: StakeClauseTransaction): StakeClauseTxnBuilder { + return this.initializeBuilder(tx, new StakeClauseTxnBuilder(this._coinConfig)); + } + /** * Gets an nft transaction builder. * diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index 6f8e9f6eb6..90463edcea 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -11,6 +11,7 @@ import { import { TRANSFER_TOKEN_METHOD_ID, STAKING_METHOD_ID, + STAKE_CLAUSE_METHOD_ID, EXIT_DELEGATION_METHOD_ID, BURN_NFT_METHOD_ID, VET_ADDRESS_LENGTH, @@ -91,6 +92,8 @@ export class Utils implements BaseUtils { return TransactionType.SendToken; } else if (clauses[0].data.startsWith(STAKING_METHOD_ID)) { return TransactionType.ContractCall; + } else if (clauses[0].data.startsWith(STAKE_CLAUSE_METHOD_ID)) { + return TransactionType.StakingActivate; } else if (clauses[0].data.startsWith(EXIT_DELEGATION_METHOD_ID)) { return TransactionType.StakingUnlock; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { @@ -151,6 +154,27 @@ export class Utils implements BaseUtils { } } + /** + * Decodes staking transaction data to extract levelId and autorenew + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing levelId and autorenew + */ + decodeStakeClauseData(data: string): { levelId: number } { + try { + const parameters = data.slice(10); + + // Decode using ethereumjs-abi directly + const decoded = EthereumAbi.rawDecode(['uint8'], Buffer.from(parameters, 'hex')); + + return { + levelId: Number(decoded[0]), + }; + } catch (error) { + throw new Error(`Failed to decode staking data: ${error.message}`); + } + } + decodeTransferTokenData(data: string): TransactionRecipient { const [address, amount] = getRawDecoded( ['address', 'uint256'], diff --git a/modules/sdk-coin-vet/test/transactionBuilder/stakeClauseTransactionBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/stakeClauseTransactionBuilder.ts new file mode 100644 index 0000000000..426c91bd16 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/stakeClauseTransactionBuilder.ts @@ -0,0 +1,254 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, StakeClauseTransaction } from '../../src/lib'; +import should from 'should'; +import { STAKE_CLAUSE_METHOD_ID, STARGATE_NFT_ADDRESS_TESTNET } from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import * as testData from '../resources/vet'; + +describe('VET Staking Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const stakingContractAddress = STARGATE_NFT_ADDRESS_TESTNET; + const amountToStake = '1000000000000000000'; // 1 VET in wei + const levelId = 8; // Test level ID + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getStakingActivateBuilder(); + 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 a staking transaction', async function () { + const txBuilder = factory.getStakingActivateBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.levelId(levelId); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + txBuilder.chainTag(0x27); // Testnet chain tag + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + + const tx = await txBuilder.build(); + should.exist(tx); + tx.should.be.instanceof(Transaction); + tx.should.be.instanceof(StakeClauseTransaction); + + const stakingTx = tx as StakeClauseTransaction; + stakingTx.stakingContractAddress.should.equal(stakingContractAddress); + stakingTx.amountToStake.should.equal(amountToStake); + stakingTx.levelId.should.equal(levelId); + + // Verify clauses + stakingTx.clauses.length.should.equal(1); + should.exist(stakingTx.clauses[0].to); + stakingTx.clauses[0].to?.should.equal(stakingContractAddress); + stakingTx.clauses[0].value.should.equal(amountToStake); + + // Verify transaction data is correctly encoded using ethereumABI + should.exist(stakingTx.clauses[0].data); + const txData = stakingTx.clauses[0].data; + txData.should.startWith(STAKE_CLAUSE_METHOD_ID); + + // Verify the encoded data matches what we expect from ethereumABI + const methodName = 'stake'; + const types = ['uint8']; + const params = [levelId]; + + 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 + stakingTx.recipients.length.should.equal(1); + stakingTx.recipients[0].address.should.equal(stakingContractAddress); + stakingTx.recipients[0].amount.should.equal(amountToStake); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.amountToStake(amountToStake); + txBuilder.levelId(levelId); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when levelId is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + + await txBuilder.build().should.be.rejectedWith('Level ID is required'); + }); + + it('should throw error when amountToStake is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.levelId(levelId); + + await txBuilder.build().should.be.rejectedWith('Amount to stake 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 throw error when amountToStake is not a valid number string', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.levelId(levelId); + + // Invalid amount (not a number) + txBuilder.amountToStake('not-a-number'); + + // Should fail when building the transaction due to invalid amount + await txBuilder.build().should.be.rejected(); + }); + + it('should allow zero amountToStake but encode it properly', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.levelId(levelId); + txBuilder.amountToStake('0'); + + const tx = await txBuilder.build(); + tx.should.be.instanceof(StakeClauseTransaction); + + const stakingTx = tx as StakeClauseTransaction; + // Verify the amount is correctly set to zero + stakingTx.amountToStake.should.equal('0'); + + // Verify the transaction data is correctly encoded with levelId for stake + // Expected data for stake(8) where 8 is levelId + const expectedData = '0x604f21770000000000000000000000000000000000000000000000000000000000000008'; + stakingTx.clauses[0].data.should.equal(expectedData); + }); + + it('should build transaction with undefined sender but include it in inputs', async function () { + const txBuilder = factory.getStakingActivateBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.levelId(levelId); + txBuilder.chainTag(0x27); + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + // Not setting sender + + const tx = await txBuilder.build(); + tx.should.be.instanceof(StakeClauseTransaction); + + const stakingTx = tx as StakeClauseTransaction; + // Verify the transaction has inputs but with undefined address + stakingTx.inputs.length.should.equal(1); + should.not.exist(stakingTx.inputs[0].address); + + // Verify the transaction has the correct output + stakingTx.outputs.length.should.equal(1); + stakingTx.outputs[0].address.should.equal(stakingContractAddress); + stakingTx.outputs[0].value.should.equal(amountToStake); + }); + + it('should use network default chainTag when not explicitly set', async function () { + const txBuilder = factory.getStakingActivateBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.levelId(levelId); + // Not setting chainTag + txBuilder.blockRef('0x0000000000000000'); + txBuilder.expiration(64); + txBuilder.gas(100000); + txBuilder.gasPriceCoef(0); + txBuilder.nonce('12345'); + txBuilder.sender('0x9378c12BD7502A11F770a5C1F223c959B2805dA9'); + + const tx = await txBuilder.build(); + tx.should.be.instanceof(StakeClauseTransaction); + + const stakingTx = tx as StakeClauseTransaction; + // Verify the chainTag is set to the testnet default (39) + stakingTx.chainTag.should.equal(39); + }); + + it('should verify ABI encoding matches expected output for different amounts', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.levelId(levelId); + + // Test with a different amount + const differentAmount = '500000000000000000'; // 0.5 VET + txBuilder.amountToStake(differentAmount); + + const tx = await txBuilder.build(); + const stakingTx = tx as StakeClauseTransaction; + + // Manually encode the expected data for stake method + const methodName = 'stake'; + const types = ['uint8']; + const params = [levelId]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + const expectedData = '0x' + Buffer.concat([method, args]).toString('hex'); + + // Verify the transaction data matches our manual encoding + stakingTx.clauses[0].data.should.equal(expectedData); + stakingTx.clauses[0].data.should.startWith(STAKE_CLAUSE_METHOD_ID); + }); + + it('should handle extremely large stake amounts correctly', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.levelId(levelId); + + // Test with a very large amount (near uint256 max) + const largeAmount = '115792089237316195423570985008687907853269984665640564039457584007913129639935'; // 2^256 - 1 + txBuilder.amountToStake(largeAmount); + + const tx = await txBuilder.build(); + const stakingTx = tx as StakeClauseTransaction; + + // Verify the amount is stored correctly + stakingTx.amountToStake.should.equal(largeAmount); + + // The data should still be properly encoded + stakingTx.clauses[0].data.should.startWith(STAKE_CLAUSE_METHOD_ID); + + // Verify recipients + stakingTx.recipients[0].amount.should.equal(largeAmount); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.STAKING_TRANSACTION); + const tx = txBuilder.transaction as StakeClauseTransaction; + const toJson = tx.toJson(); + toJson.id.should.equal('0x99325b39cd04bd1821f6f6af7b679c247e6425a4eb95eb429fa8dff477298d0e'); + toJson.stakingContractAddress?.should.equal('0x1856c533ac2d94340aaa8544d35a5c1d4a21dee7'); + toJson.amountToStake?.should.equal('0xde0b6b3a7640000'); + toJson.nonce.should.equal('609363'); + toJson.gas.should.equal(25988); + toJson.gasPriceCoef.should.equal(128); + toJson.expiration.should.equal(64); + toJson.chainTag.should.equal(39); + toJson.nftTokenId?.should.equal(8); + }); + }); +});