From e006cf292200283add118a90adb646a694399a3d Mon Sep 17 00:00:00 2001 From: Gautam2305 Date: Fri, 1 Aug 2025 11:59:24 +0530 Subject: [PATCH] feat: add stakingBuilder for vechain Ticket: SC-2596 --- modules/sdk-coin-vet/src/lib/constants.ts | 1 + modules/sdk-coin-vet/src/lib/iface.ts | 36 ++++ modules/sdk-coin-vet/src/lib/index.ts | 2 + .../src/lib/transaction/stakingTransaction.ts | 154 ++++++++++++++ .../lib/transactionBuilder/stakingBuilder.ts | 140 ++++++++++++ .../src/lib/transactionBuilderFactory.ts | 10 + modules/sdk-coin-vet/src/lib/utils.ts | 20 ++ modules/sdk-coin-vet/test/resources/vet.ts | 6 +- .../transactionBuilder/stakingTransaction.ts | 201 ++++++++++++++++++ 9 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/stakingTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/stakingBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/stakingTransaction.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index d43c42fd38..52ebd2e99b 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -3,6 +3,7 @@ export const VET_ADDRESS_LENGTH = 40; export const VET_BLOCK_ID_LENGTH = 64; export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb'; +export const STAKING_METHOD_ID = '0xa694fc3a'; export const EXIT_DELEGATION_METHOD_ID = '0x32b7006d'; export const BURN_NFT_METHOD_ID = '0x42966c68'; diff --git a/modules/sdk-coin-vet/src/lib/iface.ts b/modules/sdk-coin-vet/src/lib/iface.ts index b733efa12f..c8a6cddc13 100644 --- a/modules/sdk-coin-vet/src/lib/iface.ts +++ b/modules/sdk-coin-vet/src/lib/iface.ts @@ -4,6 +4,40 @@ import { TransactionRecipient, } from '@bitgo/sdk-core'; +/** + * Interface for ABI input parameter + */ +export interface AbiInput { + internalType: string; + name: string; + type: string; +} + +/** + * Interface for ABI output parameter + */ +export interface AbiOutput { + internalType?: string; + name?: string; + type: string; +} + +/** + * Interface for ABI function definition + */ +export interface AbiFunction { + inputs: AbiInput[]; + name: string; + outputs: AbiOutput[]; + stateMutability: string; + type: string; +} + +/** + * Type for contract ABI + */ +export type ContractAbi = AbiFunction[]; + /** * The transaction data returned from the toJson() function of a transaction */ @@ -25,6 +59,8 @@ export interface VetTransactionData { to?: string; tokenAddress?: string; tokenId?: string; // Added for unstaking and burn NFT transactions + stakingContractAddress?: string; + amountToStake?: string; } export interface VetTransactionExplanation extends BaseTransactionExplanation { diff --git a/modules/sdk-coin-vet/src/lib/index.ts b/modules/sdk-coin-vet/src/lib/index.ts index a6ff46ba88..f8148ca101 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -7,9 +7,11 @@ export { Transaction } from './transaction/transaction'; export { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; export { FlushTokenTransaction } from './transaction/flushTokenTransaction'; export { TokenTransaction } from './transaction/tokenTransaction'; +export { StakingTransaction } from './transaction/stakingTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; +export { StakingBuilder } from './transactionBuilder/stakingBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Constants, Utils, Interface }; diff --git a/modules/sdk-coin-vet/src/lib/transaction/stakingTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/stakingTransaction.ts new file mode 100644 index 0000000000..a03fa4fe9e --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/stakingTransaction.ts @@ -0,0 +1,154 @@ +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, ContractAbi } from '../iface'; +import utils from '../utils'; + +export class StakingTransaction extends Transaction { + private _stakingContractAddress: string; + private _amountToStake: string; + private _stakingContractABI: ContractAbi; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.ContractCall; + } + + get stakingContractAddress(): string { + return this._stakingContractAddress; + } + + set stakingContractAddress(address: string) { + this._stakingContractAddress = address; + } + + get amountToStake(): string { + return this._amountToStake; + } + + set amountToStake(amount: string) { + this._amountToStake = amount; + } + + get stakingContractABI(): ContractAbi { + return this._stakingContractABI; + } + + set stakingContractABI(abi: ContractAbi) { + this._stakingContractABI = abi; + } + + buildClauses(): void { + if (!this.stakingContractAddress) { + throw new Error('Staking contract address is not set'); + } + + if (!this.amountToStake) { + throw new Error('Amount to stake is not set'); + } + + // Generate transaction data using ethereumjs-abi + const data = utils.getStakingData(this.amountToStake); + 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, + }, + ]; + } + + 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, + }; + + 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; + } + } + + // Set recipients from clauses + this.recipients = body.clauses.map((clause) => ({ + address: (clause.to || '0x0').toString().toLowerCase(), + amount: String(clause.value || '0'), + })); + 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/transactionBuilder/stakingBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/stakingBuilder.ts new file mode 100644 index 0000000000..c893292bf4 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/stakingBuilder.ts @@ -0,0 +1,140 @@ +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 { StakingTransaction } from '../transaction/stakingTransaction'; +import { ContractAbi } from '../iface'; +import utils from '../utils'; + +export class StakingBuilder extends TransactionBuilder { + /** + * Creates a new StakingBuilder instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new StakingTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing StakingTransaction. + * + * @param {StakingTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: StakingTransaction): void { + this._transaction = tx; + } + + /** + * Gets the staking transaction instance. + * + * @returns {StakingTransaction} The staking transaction + */ + get stakingTransaction(): StakingTransaction { + return this._transaction as StakingTransaction; + } + + /** + * Gets the transaction type for staking. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.ContractCall; + } + + /** + * 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. + * + * @param {string} address - The staking contract address + * @returns {StakingBuilder} This transaction builder + */ + stakingContractAddress(address: string): this { + this.validateAddress({ address }); + this.stakingTransaction.stakingContractAddress = address; + return this; + } + + /** + * Sets the amount to stake for this staking tx. + * + * @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 staking contract ABI for this staking tx. + * + * @param {ContractAbi} abi - The staking contract ABI + * @returns {StakingBuilder} This transaction builder + */ + stakingContractABI(abi: ContractAbi): this { + this.stakingTransaction.stakingContractABI = abi; + 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?: StakingTransaction): 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'); + + 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 eb4fc10239..c7ba2ca88c 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -15,6 +15,8 @@ import { ExitDelegationTransaction } from './transaction/exitDelegation'; import { BurnNftTransaction } from './transaction/burnNftTransaction'; import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBuilder'; import { TokenTransaction } from './transaction/tokenTransaction'; +import { StakingBuilder } from './transactionBuilder/stakingBuilder'; +import { StakingTransaction } from './transaction/stakingTransaction'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -43,6 +45,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const tokenTransferTx = new TokenTransaction(this._coinConfig); tokenTransferTx.fromDeserializedSignedTransaction(signedTx); return this.getTokenTransactionBuilder(tokenTransferTx); + case TransactionType.ContractCall: + const stakingTx = new StakingTransaction(this._coinConfig); + stakingTx.fromDeserializedSignedTransaction(signedTx); + return this.getStakingBuilder(stakingTx); case TransactionType.StakingUnlock: const exitDelegationTx = new ExitDelegationTransaction(this._coinConfig); exitDelegationTx.fromDeserializedSignedTransaction(signedTx); @@ -76,6 +82,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new TokenTransactionBuilder(this._coinConfig)); } + getStakingBuilder(tx?: StakingTransaction): StakingBuilder { + return this.initializeBuilder(tx, new StakingBuilder(this._coinConfig)); + } + /** * Gets an exit delegation transaction builder. * diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index 5cd52829e7..befdd69bcb 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -10,6 +10,7 @@ import { } from '@bitgo/abstract-eth'; import { TRANSFER_TOKEN_METHOD_ID, + STAKING_METHOD_ID, EXIT_DELEGATION_METHOD_ID, BURN_NFT_METHOD_ID, VET_ADDRESS_LENGTH, @@ -80,6 +81,8 @@ export class Utils implements BaseUtils { return TransactionType.FlushTokens; } else if (clauses[0].data.startsWith(TRANSFER_TOKEN_METHOD_ID)) { return TransactionType.SendToken; + } else if (clauses[0].data.startsWith(STAKING_METHOD_ID)) { + return TransactionType.ContractCall; } else if (clauses[0].data.startsWith(EXIT_DELEGATION_METHOD_ID)) { return TransactionType.StakingUnlock; // Using StakingUnlock for exit delegation } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { @@ -100,6 +103,23 @@ export class Utils implements BaseUtils { return addHexPrefix(Buffer.concat([method, args]).toString('hex')); } + /** + * Encodes staking transaction data using ethereumjs-abi + * + * @param {string} stakingAmount - The amount to stake in wei + * @returns {string} - The encoded transaction data + */ + getStakingData(stakingAmount: string): string { + const methodName = 'stake'; + const types = ['uint256']; + const params = [new BN(stakingAmount)]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + decodeTransferTokenData(data: string): TransactionRecipient { const [address, amount] = getRawDecoded( ['address', 'uint256'], diff --git a/modules/sdk-coin-vet/test/resources/vet.ts b/modules/sdk-coin-vet/test/resources/vet.ts index 724860379c..a180569831 100644 --- a/modules/sdk-coin-vet/test/resources/vet.ts +++ b/modules/sdk-coin-vet/test/resources/vet.ts @@ -1,5 +1,4 @@ import { Recipient } from '@bitgo/sdk-core'; -import { Networks } from '@bitgo/statics'; export const AMOUNT = 100000000000000000; // 0.1 VET in base units @@ -128,9 +127,10 @@ export const feePayer = { address: '0xdc9fef0b84a0ccf3f1bd4b84e41743e3e051a083', }; -export const FORWARDER_FACTORY_ADDRESS = Networks.test.vet.forwarderFactoryAddress; +// Use the hardcoded values from the VetTestnet class in networks.ts +export const FORWARDER_FACTORY_ADDRESS = '0x65343e18c376d2fc8c3cf10cd146d63e2e0dc9ef'; -export const FORWARDER_IMPLEMENTATION_ADDRESS = Networks.test.vet.forwarderImplementationAddress; +export const FORWARDER_IMPLEMENTATION_ADDRESS = '0x62de34c87f847d385af07f6c25dbd97b1fffefc0'; export const CREATE_FORWARDER_METHOD = '0x13b2f75c'; diff --git a/modules/sdk-coin-vet/test/transactionBuilder/stakingTransaction.ts b/modules/sdk-coin-vet/test/transactionBuilder/stakingTransaction.ts new file mode 100644 index 0000000000..a3a29f05bc --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/stakingTransaction.ts @@ -0,0 +1,201 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, StakingTransaction } from '../../src/lib'; +import should from 'should'; +import { STAKING_METHOD_ID } from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import { BN } from 'ethereumjs-util'; + +describe('VET Staking Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const stakingContractAddress = '0x1EC1D168574603ec35b9d229843B7C2b44bCB770'; + const amountToStake = '1000000000000000000'; // 1 VET in wei + const stakingContractABI = [ + { + inputs: [ + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'stake', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + ]; + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getStakingBuilder(); + 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.getStakingBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.stakingContractABI(stakingContractABI); + 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(StakingTransaction); + + const stakingTx = tx as StakingTransaction; + stakingTx.stakingContractAddress.should.equal(stakingContractAddress); + stakingTx.amountToStake.should.equal(amountToStake); + stakingTx.stakingContractABI.should.deepEqual(stakingContractABI); + + // 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(STAKING_METHOD_ID); + + // Verify the encoded data matches what we expect from ethereumABI + const methodName = 'stake'; + const types = ['uint256']; + const params = [new BN(amountToStake)]; + + 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.stakingContractABI(stakingContractABI); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when amountToStake is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.stakingContractABI(stakingContractABI); + + 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 allow zero amountToStake but encode it properly', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.stakingContractABI(stakingContractABI); + txBuilder.amountToStake('0'); + + const tx = await txBuilder.build(); + tx.should.be.instanceof(StakingTransaction); + + const stakingTx = tx as StakingTransaction; + // Verify the amount is correctly set to zero + stakingTx.amountToStake.should.equal('0'); + + // Verify the transaction data is correctly encoded with zero amount + const expectedData = '0xa694fc3a0000000000000000000000000000000000000000000000000000000000000000'; + stakingTx.clauses[0].data.should.equal(expectedData); + }); + + it('should generate correct transaction data even without explicitly setting stakingContractABI', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + // Not setting stakingContractABI + + const tx = await txBuilder.build(); + tx.should.be.instanceof(StakingTransaction); + + const stakingTx = tx as StakingTransaction; + // Verify the transaction data is correctly generated using the default staking method ID + stakingTx.clauses[0].data.should.startWith(STAKING_METHOD_ID); + // Verify the encoded amount matches what we expect + const expectedData = '0xa694fc3a0000000000000000000000000000000000000000000000000de0b6b3a7640000'; + stakingTx.clauses[0].data.should.equal(expectedData); + }); + + it('should build transaction with undefined sender but include it in inputs', async function () { + const txBuilder = factory.getStakingBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.stakingContractABI(stakingContractABI); + 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(StakingTransaction); + + const stakingTx = tx as StakingTransaction; + // 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.getStakingBuilder(); + txBuilder.stakingContractAddress(stakingContractAddress); + txBuilder.amountToStake(amountToStake); + txBuilder.stakingContractABI(stakingContractABI); + // 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(StakingTransaction); + + const stakingTx = tx as StakingTransaction; + // Verify the chainTag is set to the testnet default (39) + stakingTx.chainTag.should.equal(39); + }); + }); +});