diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index 2978caf9dd..2d7873733c 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -1,10 +1,12 @@ export const VET_TRANSACTION_ID_LENGTH = 64; export const VET_ADDRESS_LENGTH = 40; export const VET_BLOCK_ID_LENGTH = 64; +export const ZERO_VALUE_AMOUNT = '0'; export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb'; export const STAKING_METHOD_ID = '0xd8da3bbf'; export const STAKE_CLAUSE_METHOD_ID = '0x604f2177'; +export const DELEGATE_CLAUSE_METHOD_ID = '0x3207555d'; 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 276a42af28..2872751819 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -9,6 +9,7 @@ export { FlushTokenTransaction } from './transaction/flushTokenTransaction'; export { TokenTransaction } from './transaction/tokenTransaction'; export { StakingTransaction } from './transaction/stakingTransaction'; export { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; +export { DelegateClauseTransaction } from './transaction/delegateClauseTransaction'; export { ExitDelegationTransaction } from './transaction/exitDelegation'; export { BurnNftTransaction } from './transaction/burnNftTransaction'; export { ClaimRewardsTransaction } from './transaction/claimRewards'; @@ -19,6 +20,7 @@ export { AddressInitializationBuilder } from './transactionBuilder/addressInitia export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; export { StakingBuilder } from './transactionBuilder/stakingBuilder'; export { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; +export { DelegateTxnBuilder } from './transactionBuilder/delegateTxnBuilder'; 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/delegateClauseTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/delegateClauseTransaction.ts new file mode 100644 index 0000000000..c588e16010 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/delegateClauseTransaction.ts @@ -0,0 +1,177 @@ +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 DelegateClauseTransaction extends Transaction { + private _stakingContractAddress: string; + private _tokenId: number; + private _delegateForever = true; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingDelegate; + } + + get stakingContractAddress(): string { + return this._stakingContractAddress; + } + + set stakingContractAddress(address: string) { + this._stakingContractAddress = address; + } + + get tokenId(): number { + return this._tokenId; + } + + set tokenId(tokenId: number) { + this._tokenId = tokenId; + } + + get delegateForever(): boolean { + return this._delegateForever; + } + + set delegateForever(delegateForever: boolean) { + this._delegateForever = delegateForever; + } + + buildClauses(): void { + if (!this.stakingContractAddress) { + throw new Error('Staking contract address is not set'); + } + + utils.validateDelegationContractAddress(this.stakingContractAddress, this._coinConfig); + + if (this.tokenId === undefined || this.tokenId === null) { + throw new Error('Token ID is not set'); + } + + const data = this.getDelegateData(this.tokenId, this.delegateForever); + this._transactionData = data; + + // Create the clause for delegation + this._clauses = [ + { + to: this.stakingContractAddress, + value: ZERO_VALUE_AMOUNT, + data: this._transactionData, + }, + ]; + + // Set recipients based on the clauses + this._recipients = [ + { + address: this.stakingContractAddress, + amount: ZERO_VALUE_AMOUNT, + }, + ]; + } + /** + * Encodes delegation transaction data using ethereumjs-abi for delegate method + * + * @param {number} tokenId - The Token ID for delegation + * @returns {string} - The encoded transaction data + */ + getDelegateData(levelId: number, delegateForever = true): string { + const methodName = 'delegate'; + const types = ['uint256', 'bool']; + const params = [levelId, delegateForever]; + + 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: ZERO_VALUE_AMOUNT, + sender: this.sender, + to: this.stakingContractAddress, + stakingContractAddress: this.stakingContractAddress, + amountToStake: ZERO_VALUE_AMOUNT, + nftTokenId: this.tokenId, + autorenew: this.delegateForever, + }; + + 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 delegation-specific properties + 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.decodeDelegateClauseData(clause.data); + this.tokenId = decoded.tokenId; + this.delegateForever = decoded.delegateForever; + } + } + + // 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 5a8fc6677e..b9a3cc355e 100644 --- a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts @@ -390,6 +390,7 @@ export class Transaction extends BaseTransaction { this.type === TransactionType.SendNFT || this.type === TransactionType.ContractCall || this.type === TransactionType.StakingActivate || + this.type === TransactionType.StakingDelegate || this.type === TransactionType.StakingUnlock || this.type === TransactionType.StakingWithdraw || this.type === TransactionType.StakingClaim diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/delegateTxnBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/delegateTxnBuilder.ts new file mode 100644 index 0000000000..137c23758e --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/delegateTxnBuilder.ts @@ -0,0 +1,128 @@ +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 { DelegateClauseTransaction } from '../transaction/delegateClauseTransaction'; +import utils from '../utils'; + +export class DelegateTxnBuilder extends TransactionBuilder { + /** + * Creates a new Delegate Clause txn instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new DelegateClauseTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing Delegate txn. + * + * @param {DelegateClauseTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: DelegateClauseTransaction): void { + this._transaction = tx; + } + + /** + * Gets the staking transaction instance. + * + * @returns {DelegateClauseTransaction} The delegate transaction + */ + get delegateTransaction(): DelegateClauseTransaction { + return this._transaction as DelegateClauseTransaction; + } + + /** + * Gets the transaction type for delegate. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingDelegate; + } + + /** + * Validates the transaction clauses for delegate 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 delegate tx. + * The address must be explicitly provided to ensure the correct contract is used. + * + * @param {string} address - The staking contract address (required) + * @returns {DelegateTxnBuilder} 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.delegateTransaction.stakingContractAddress = address; + return this; + } + + /** + * Sets the token ID for this delegate tx. + * + * @param {number} levelId - The level ID for staking + * @returns {DelegateTxnBuilder} This transaction builder + */ + tokenId(tokenId: number): this { + this.delegateTransaction.tokenId = tokenId; + return this; + } + + /** + * Sets the transaction data for this delegate tx. + * + * @param {string} data - The transaction data + * @returns {DelegateTxnBuilder} This transaction builder + */ + transactionData(data: string): this { + this.delegateTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: DelegateClauseTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + + assert(transaction.tokenId, 'Token ID is required'); + assert(transaction.delegateForever, 'delegate forever flag is required'); + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.delegateTransaction.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 0dec5e5e74..12bc477243 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -23,6 +23,8 @@ import { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilde import { NFTTransaction } from './transaction/nftTransaction'; import { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; import { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; +import { DelegateTxnBuilder } from './transactionBuilder/delegateTxnBuilder'; +import { DelegateClauseTransaction } from './transaction/delegateClauseTransaction'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -63,6 +65,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const stakeClauseTx = new StakeClauseTransaction(this._coinConfig); stakeClauseTx.fromDeserializedSignedTransaction(signedTx); return this.getStakingActivateBuilder(stakeClauseTx); + case TransactionType.StakingDelegate: + const delegateClauseTx = new DelegateClauseTransaction(this._coinConfig); + delegateClauseTx.fromDeserializedSignedTransaction(signedTx); + return this.getStakingDelegateBuilder(delegateClauseTx); case TransactionType.StakingUnlock: const exitDelegationTx = new ExitDelegationTransaction(this._coinConfig); exitDelegationTx.fromDeserializedSignedTransaction(signedTx); @@ -104,6 +110,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new StakingBuilder(this._coinConfig)); } + getStakingDelegateBuilder(tx?: DelegateClauseTransaction): DelegateTxnBuilder { + return this.initializeBuilder(tx, new DelegateTxnBuilder(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 90463edcea..90d1858af0 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -24,6 +24,7 @@ import { STARGATE_NFT_ADDRESS_TESTNET, STARGATE_DELEGATION_ADDRESS, STARGATE_DELEGATION_ADDRESS_TESTNET, + DELEGATE_CLAUSE_METHOD_ID, } from './constants'; import { KeyPair } from './keyPair'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; @@ -94,6 +95,8 @@ export class Utils implements BaseUtils { return TransactionType.ContractCall; } else if (clauses[0].data.startsWith(STAKE_CLAUSE_METHOD_ID)) { return TransactionType.StakingActivate; + } else if (clauses[0].data.startsWith(DELEGATE_CLAUSE_METHOD_ID)) { + return TransactionType.StakingDelegate; } else if (clauses[0].data.startsWith(EXIT_DELEGATION_METHOD_ID)) { return TransactionType.StakingUnlock; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { @@ -175,6 +178,28 @@ export class Utils implements BaseUtils { } } + /** + * Decodes delegate transaction data to extract tokenId and delegateForever + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing tokenId and delegateForever + */ + decodeDelegateClauseData(data: string): { tokenId: number; delegateForever: boolean } { + try { + const parameters = data.slice(10); + + // Decode using ethereumjs-abi directly + const decoded = EthereumAbi.rawDecode(['uint256', 'bool'], Buffer.from(parameters, 'hex')); + + return { + tokenId: Number(decoded[0]), + delegateForever: Boolean(decoded[1]), + }; + } catch (error) { + throw new Error(`Failed to decode delegation data: ${error.message}`); + } + } + 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 07a4b3cb77..a3aebc9f97 100644 --- a/modules/sdk-coin-vet/test/resources/vet.ts +++ b/modules/sdk-coin-vet/test/resources/vet.ts @@ -14,6 +14,9 @@ export const SPONSORED_NFT_TRANSACTION = export const STAKING_TRANSACTION = '0xf901032788015d55fcf2457e7c40f866f864941856c533ac2d94340aaa8544d35a5c1d4a21dee7880de0b6b3a7640000b844d8da3bbf0000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000181808265848083094c53c101b882efcb9ea88e908d1a142db96c1b44dd056ea194f1ad45670c100a8c52348cc7b20387741260ebe7fe9b7594f96693c88662fa60edba5992332728222b0bdd8a30008535368bd901319eb4513d16bebc428dc8454d32a19eeb76372849a6134ebbba79f1eeceea1f6546574b945c05489222cb451f5b0e2901b0c687b750e833aeb800'; +export const DELEGATION_TRANSACTION = + '0xf8fb278801618aa3e0a55fc940f85ef85c947240e3bc0d26431512d5b67dbd26d199205bffe880b8443207555d00000000000000000000000000000000000000000000000000000000000187690000000000000000000000000000000000000000000000000000000000000001818082e43a808306af07c101b882fb8030f6e2ef6563ff3b0e7e2a2292c1db5fc41c7ab9f598bad370c5cfd3dc32286ae8d709e941c0312c8cd33a3505156b44d1639c73980ffa66bc72f37820f2001c0e6b6e76a6a4d806c377a0a279053eb6ea4356bd235f4396585bb071d70f992c639d45c53431a3c1493a52a136203905e42c671dd384ee5f5ead0a70cb607001'; + export const STAKING_LEVEL_ID = 8; export const STAKING_AUTORENEW = true; export const STAKING_CONTRACT_ADDRESS = '0x1ec1d168574603ec35b9d229843b7c2b44bcb770'; diff --git a/modules/sdk-coin-vet/test/transactionBuilder/delegateClauseTxnBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/delegateClauseTxnBuilder.ts new file mode 100644 index 0000000000..0546106c79 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/delegateClauseTxnBuilder.ts @@ -0,0 +1,158 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, DelegateClauseTransaction } from '../../src/lib'; +import should from 'should'; +import { DELEGATE_CLAUSE_METHOD_ID, STARGATE_DELEGATION_ADDRESS_TESTNET } from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import * as testData from '../resources/vet'; + +describe('VET Delegation Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const tokenId = 100201; // Test level ID + const delegateForever = true; // Test delegateForever flag + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getStakingDelegateBuilder(); + 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 delegate transaction', async function () { + const txBuilder = factory.getStakingDelegateBuilder(); + txBuilder.stakingContractAddress(STARGATE_DELEGATION_ADDRESS_TESTNET); + txBuilder.tokenId(tokenId); + 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(DelegateClauseTransaction); + + const delegationTx = tx as DelegateClauseTransaction; + delegationTx.stakingContractAddress.should.equal(STARGATE_DELEGATION_ADDRESS_TESTNET); + delegationTx.tokenId.should.equal(tokenId); + delegationTx.delegateForever.should.equal(delegateForever); + + // Verify clauses + delegationTx.clauses.length.should.equal(1); + should.exist(delegationTx.clauses[0].to); + delegationTx.clauses[0].to?.should.equal(STARGATE_DELEGATION_ADDRESS_TESTNET); + + // Verify transaction data is correctly encoded using ethereumABI + should.exist(delegationTx.clauses[0].data); + const txData = delegationTx.clauses[0].data; + txData.should.startWith(DELEGATE_CLAUSE_METHOD_ID); + + // Verify the encoded data matches what we expect from ethereumABI + const methodName = 'delegate'; + const types = ['uint256', 'bool']; + const params = [tokenId, delegateForever]; + + 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 + delegationTx.recipients.length.should.equal(1); + delegationTx.recipients[0].address.should.equal(STARGATE_DELEGATION_ADDRESS_TESTNET); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.tokenId(tokenId); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when tokenId is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingContractAddress(STARGATE_DELEGATION_ADDRESS_TESTNET); + + await txBuilder.build().should.be.rejectedWith('Token ID 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.getStakingDelegateBuilder(); + txBuilder.stakingContractAddress(STARGATE_DELEGATION_ADDRESS_TESTNET); + txBuilder.tokenId(tokenId); + 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(DelegateClauseTransaction); + + const delegationTx = tx as DelegateClauseTransaction; + // Verify the transaction has inputs but with undefined address + delegationTx.inputs.length.should.equal(1); + should.not.exist(delegationTx.inputs[0].address); + + // Verify the transaction has the correct output + delegationTx.outputs.length.should.equal(1); + delegationTx.outputs[0].address.should.equal(STARGATE_DELEGATION_ADDRESS_TESTNET); + }); + + it('should use network default chainTag when not explicitly set', async function () { + const txBuilder = factory.getStakingDelegateBuilder(); + txBuilder.stakingContractAddress(STARGATE_DELEGATION_ADDRESS_TESTNET); + txBuilder.tokenId(tokenId); + // 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(DelegateClauseTransaction); + + const delegationTx = tx as DelegateClauseTransaction; + // Verify the chainTag is set to the testnet default (39) + delegationTx.chainTag.should.equal(39); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.DELEGATION_TRANSACTION); + const tx = txBuilder.transaction as DelegateClauseTransaction; + const toJson = tx.toJson(); + toJson.id.should.equal('0x4d41f712736a528e85cf8cefaee61f7842a6ddd5f20977b8d97182391dee6f42'); + toJson.stakingContractAddress?.should.equal('0x7240e3bc0d26431512d5b67dbd26d199205bffe8'); + toJson.nonce.should.equal('438023'); + toJson.gas.should.equal(58426); + toJson.gasPriceCoef.should.equal(128); + toJson.expiration.should.equal(64); + toJson.chainTag.should.equal(39); + toJson.nftTokenId?.should.equal(tokenId); + toJson.autorenew?.should.equal(true); + }); + }); +});