From b8a4f439a6aa6ccb30d1eab3ec2840807907036c Mon Sep 17 00:00:00 2001 From: Vijay Jagannathan Date: Fri, 12 Dec 2025 12:19:22 +0530 Subject: [PATCH] feat(sdk-coin-vet): add support for validator registration Ticket: SC-4409 --- modules/sdk-coin-vet/src/lib/constants.ts | 4 + modules/sdk-coin-vet/src/lib/iface.ts | 1 + modules/sdk-coin-vet/src/lib/index.ts | 2 + .../validatorRegistrationTransaction.ts | 185 ++++++++++++++++ .../validatorRegistrationBuilder.ts | 142 +++++++++++++ .../src/lib/transactionBuilderFactory.ts | 10 + modules/sdk-coin-vet/src/lib/utils.ts | 54 +++++ modules/sdk-coin-vet/test/resources/vet.ts | 13 +- .../validatorRegistrationTxnBuilder.ts | 197 ++++++++++++++++++ 9 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/validatorRegistrationTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/validatorRegistrationBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/validatorRegistrationTxnBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index 6e44cf5085..93cf48a8ca 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -7,6 +7,7 @@ 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 = '0x08bbb824'; +export const ADD_VALIDATION_METHOD_ID = '0xc3c4b138'; export const EXIT_DELEGATION_METHOD_ID = '0x69e79b7d'; export const BURN_NFT_METHOD_ID = '0x2e17de78'; export const TRANSFER_NFT_METHOD_ID = '0x23b872dd'; @@ -20,6 +21,9 @@ export const STARGATE_DELEGATION_ADDRESS_TESTNET = '0x7240e3bc0d26431512d5b67dbd export const STARGATE_NFT_ADDRESS_TESTNET = '0x887d9102f0003f1724d8fd5d4fe95a11572fcd77'; export const STARGATE_CONTRACT_ADDRESS_TESTNET = '0x1e02b2953adefec225cf0ec49805b1146a4429c1'; +export const VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET = '0x00000000000000000000000000005374616B6572'; +export const VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_MAINNET = '0x00000000000000000000000000005374616B6572'; + export const AVG_GAS_UNITS = '21000'; export const EXPIRATION = 400; export const GAS_PRICE_COEF = '128'; diff --git a/modules/sdk-coin-vet/src/lib/iface.ts b/modules/sdk-coin-vet/src/lib/iface.ts index f3be344a03..15c94b418e 100644 --- a/modules/sdk-coin-vet/src/lib/iface.ts +++ b/modules/sdk-coin-vet/src/lib/iface.ts @@ -33,6 +33,7 @@ export interface VetTransactionData { autorenew?: boolean; // Autorenew flag for stakeAndDelegate method nftCollectionId?: string; validatorAddress?: string; + stakingPeriod?: number; } 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 2872751819..6a61e5e74f 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -14,6 +14,7 @@ export { ExitDelegationTransaction } from './transaction/exitDelegation'; export { BurnNftTransaction } from './transaction/burnNftTransaction'; export { ClaimRewardsTransaction } from './transaction/claimRewards'; export { NFTTransaction } from './transaction/nftTransaction'; +export { ValidatorRegistrationTransaction } from './transaction/validatorRegistrationTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; @@ -25,5 +26,6 @@ export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilde export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; export { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; +export { ValidatorRegistrationBuilder } from './transactionBuilder/validatorRegistrationBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Constants, Utils, Interface }; diff --git a/modules/sdk-coin-vet/src/lib/transaction/validatorRegistrationTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/validatorRegistrationTransaction.ts new file mode 100644 index 0000000000..858d26fcdc --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/validatorRegistrationTransaction.ts @@ -0,0 +1,185 @@ +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, BN } from 'ethereumjs-util'; +import { ZERO_VALUE_AMOUNT } from '../constants'; + +export class ValidatorRegistrationTransaction extends Transaction { + private _stakingContractAddress: string; + private _validator: string; + private _stakingPeriod: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingLock; + } + + get validator(): string { + return this._validator; + } + + set validator(address: string) { + this._validator = address; + } + + get stakingPeriod(): number { + return this._stakingPeriod; + } + + set stakingPeriod(period: number) { + this._stakingPeriod = period; + } + + 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'); + } + + if (!this.stakingPeriod) { + throw new Error('Staking period is not set'); + } + + utils.validateContractAddressForValidatorRegistration(this.stakingContractAddress, this._coinConfig); + const addValidationData = this.getAddValidationClauseData(this.validator, this.stakingPeriod); + this._transactionData = addValidationData; + // Create the clause for delegation + this._clauses = [ + { + to: this.stakingContractAddress, + value: ZERO_VALUE_AMOUNT, + data: addValidationData, + }, + ]; + + // Set recipients based on the clauses + this._recipients = [ + { + address: this.stakingContractAddress, + amount: ZERO_VALUE_AMOUNT, + }, + ]; + } + + /** + * Encodes addValidation transaction data using ethereumjs-abi for addValidation method + * @param {string} validator - address of the validator + * @param {number} period - staking period, denoted in blocks, that the Validator commits to hard +locking their VET into the built-in staker contract. Allowed values are 60480 (7 days), +129600 (15 days) or 259200 (30 days) + * @returns {string} - The encoded transaction data + */ + getAddValidationClauseData(validator: string, period: number): string { + const methodName = 'addValidation'; + const types = ['address', 'uint32']; + const params = [validator, new BN(period)]; + + 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, + validatorAddress: this.validator, + stakingPeriod: this.stakingPeriod, + }; + + 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 validator registration-specific properties + if (body.clauses.length > 0) { + // Get the addValidation clause + const addValidationClause = body.clauses[0]; + if (addValidationClause.to) { + this.stakingContractAddress = addValidationClause.to; + } + + // Extract validator and period from addValidation data + if (addValidationClause.data) { + this.transactionData = addValidationClause.data; + const decoded = utils.decodeAddValidationData(addValidationClause.data); + this.validator = decoded.validator; + this.stakingPeriod = decoded.period; + } + } + + // 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/transactionBuilder/validatorRegistrationBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/validatorRegistrationBuilder.ts new file mode 100644 index 0000000000..30dee610d6 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/validatorRegistrationBuilder.ts @@ -0,0 +1,142 @@ +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 { ValidatorRegistrationTransaction } from '../transaction/validatorRegistrationTransaction'; +import utils from '../utils'; + +export class ValidatorRegistrationBuilder extends TransactionBuilder { + /** + * Creates a new add validation Clause txn instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new ValidatorRegistrationTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing validation registration txn. + * + * @param {ValidatorRegistrationTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: ValidatorRegistrationTransaction): void { + this._transaction = tx; + } + + /** + * Gets the staking transaction instance. + * + * @returns {ValidatorRegistrationTransaction} The validator registration transaction + */ + get validatorRegistrationTransaction(): ValidatorRegistrationTransaction { + return this._transaction as ValidatorRegistrationTransaction; + } + + /** + * Gets the transaction type for validator registration. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingLock; + } + + /** + * Validates the transaction clauses for validator registration 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 validator registration tx. + * The address must be explicitly provided to ensure the correct contract is used. + * + * @param {string} address - The contract address (required) + * @returns {ValidatorRegistrationBuilder} 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.validatorRegistrationTransaction.stakingContractAddress = address; + return this; + } + + /** + * Sets the staking period for this validator registration tx. + * + * @param {number} period - The staking period + * @returns {ValidatorRegistrationBuilder} This transaction builder + */ + stakingPeriod(period: number): this { + this.validatorRegistrationTransaction.stakingPeriod = period; + return this; + } + + /** + * Sets the validator address for this validator registration tx. + * @param {string} address - The validator address + * @returns {ValidatorRegistrationBuilder} This transaction builder + */ + validator(address: string): this { + if (!address) { + throw new Error('Validator address is required'); + } + this.validateAddress({ address }); + this.validatorRegistrationTransaction.validator = address; + return this; + } + + /** + * Sets the transaction data for this validator registration tx. + * + * @param {string} data - The transaction data + * @returns {ValidatorRegistrationBuilder} This transaction builder + */ + transactionData(data: string): this { + this.validatorRegistrationTransaction.transactionData = data; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: ValidatorRegistrationTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.stakingContractAddress, 'Staking contract address is required'); + + assert(transaction.stakingPeriod, 'Staking period is required'); + assert(transaction.validator, 'Validator address is required'); + this.validateAddress({ address: transaction.stakingContractAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.validatorRegistrationTransaction.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 12bc477243..f21bcfb347 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -25,6 +25,8 @@ import { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; import { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; import { DelegateTxnBuilder } from './transactionBuilder/delegateTxnBuilder'; import { DelegateClauseTransaction } from './transaction/delegateClauseTransaction'; +import { ValidatorRegistrationTransaction } from './transaction/validatorRegistrationTransaction'; +import { ValidatorRegistrationBuilder } from './transactionBuilder/validatorRegistrationBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -81,6 +83,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const claimRewardsTx = new ClaimRewardsTransaction(this._coinConfig); claimRewardsTx.fromDeserializedSignedTransaction(signedTx); return this.getClaimRewardsBuilder(claimRewardsTx); + case TransactionType.StakingLock: + const validatorRegistrationTx = new ValidatorRegistrationTransaction(this._coinConfig); + validatorRegistrationTx.fromDeserializedSignedTransaction(signedTx); + return this.getValidatorRegistrationBuilder(validatorRegistrationTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -114,6 +120,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new DelegateTxnBuilder(this._coinConfig)); } + getValidatorRegistrationBuilder(tx?: ValidatorRegistrationTransaction): ValidatorRegistrationBuilder { + return this.initializeBuilder(tx, new ValidatorRegistrationBuilder(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 71e989485e..cf6c2cdf35 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -26,6 +26,9 @@ import { DELEGATE_CLAUSE_METHOD_ID, STARGATE_CONTRACT_ADDRESS_TESTNET, STARGATE_DELEGATION_ADDRESS_TESTNET, + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_MAINNET, + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET, + ADD_VALIDATION_METHOD_ID, } from './constants'; import { KeyPair } from './keyPair'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; @@ -100,6 +103,8 @@ export class Utils implements BaseUtils { return TransactionType.StakingDelegate; } else if (clauses[0].data.startsWith(EXIT_DELEGATION_METHOD_ID)) { return TransactionType.StakingUnlock; + } else if (clauses[0].data.startsWith(ADD_VALIDATION_METHOD_ID)) { + return TransactionType.StakingLock; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { return TransactionType.StakingWithdraw; } else if ( @@ -258,6 +263,28 @@ export class Utils implements BaseUtils { } } + /** + * Decodes add validation transaction data to extract validator address and staking period + * + * @param {string} data - The encoded transaction data + * @returns {object} - Object containing validator address and staking period + */ + decodeAddValidationData(data: string): { validator: string; period: number } { + try { + const parameters = data.slice(10); + + // Decode using ethereumjs-abi directly + const decoded = EthereumAbi.rawDecode(['address', 'uint32'], Buffer.from(parameters, 'hex')); + + return { + validator: addHexPrefix(decoded[0].toString()).toLowerCase(), + period: Number(decoded[1]), + }; + } catch (error) { + throw new Error(`Failed to decode add validation data: ${error.message}`); + } + } + /** * Decodes exit delegation transaction data to extract tokenId * @@ -318,6 +345,18 @@ export class Utils implements BaseUtils { return isTestnet ? STARGATE_CONTRACT_ADDRESS_TESTNET : STARGATE_NFT_ADDRESS; } + /** + * Get the network-appropriate contract address for validator registration + * @param {CoinConfig} coinConfig - The coin configuration object + * @returns {string} The contract address for the network + */ + getContractAddressForValidatorRegistration(coinConfig: Readonly): string { + const isTestnet = coinConfig.network.type === 'testnet'; + return isTestnet + ? VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET + : VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_MAINNET; + } + /** * Check if an address is a valid delegation contract address for any network * @param {string} address - The address to check @@ -358,6 +397,21 @@ export class Utils implements BaseUtils { } } + /** + * Validate that the contract address matches the expected stargate address for the network + * @param {string} address - The contract address to validate + * @param {CoinConfig} coinConfig - The coin configuration object + * @throws {Error} If the address doesn't match the expected contract address + */ + validateContractAddressForValidatorRegistration(address: string, coinConfig: Readonly): void { + const expectedAddress = this.getContractAddressForValidatorRegistration(coinConfig); + if (address.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new Error( + `Invalid contract address for validator registration. Expected ${expectedAddress} for ${coinConfig.network.type}, got ${address}` + ); + } + } + /** * Validate that a contract address matches the expected stargate contract for the network * @param {string} address - The contract address to validate diff --git a/modules/sdk-coin-vet/test/resources/vet.ts b/modules/sdk-coin-vet/test/resources/vet.ts index 39325cd256..bff3738c73 100644 --- a/modules/sdk-coin-vet/test/resources/vet.ts +++ b/modules/sdk-coin-vet/test/resources/vet.ts @@ -15,10 +15,13 @@ export const STAKING_TRANSACTION = '0xf901032788015d55fcf2457e7c40f866f864941856c533ac2d94340aaa8544d35a5c1d4a21dee7880de0b6b3a7640000b844d8da3bbf0000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000181808265848083094c53c101b882efcb9ea88e908d1a142db96c1b44dd056ea194f1ad45670c100a8c52348cc7b20387741260ebe7fe9b7594f96693c88662fa60edba5992332728222b0bdd8a30008535368bd901319eb4513d16bebc428dc8454d32a19eeb76372849a6134ebbba79f1eeceea1f6546574b945c05489222cb451f5b0e2901b0c687b750e833aeb800'; export const STAKE_CLAUSE_TRANSACTION = - '0xf8e5278801638298c53767ac40f847f845941e02b2953adefec225cf0ec49805b1146a4429c18a021e19e0c9bab2400000a4604f2177000000000000000000000000000000000000000000000000000000000000000881808306338a80830f340bc101b882b4970b0c160552162de719b9ed1fa6268bbfe9b36fd4e4a5c13e956cf539e60478f69179e8db4a3106fdbe775e3d923510b16f48da56c66076d1f66ffd822abf01ebc040c795b1e17cb0ca5ba747e3a181b4eefea7db5378f64f82361bdb7745da45aba2ace3db9822b675474552f13849052987c431cd4867813c2cf635302b1101'; + '0xf8ec27880166542f43c17eeb820190f847f845941e02b2953adefec225cf0ec49805b1146a4429c18a021e19e0c9bab2400000a4604f21770000000000000000000000000000000000000000000000000000000000000008818083052ac78088f536788f0c121df3c101b882ac29a0bc2bc003c9dceab58a07a59b66125928a9c4c753f7b71808da86a462b33a35b641aa375dec3312be8dcdf123949ed5344db2f5955cbab7941eeb9e857500529c6566d7f332cbcf5970a697ffe047fa3cbacf02dbbdbe0f21a17bab1b6b173e22ed64241aaac65db1f37e21600e66e9243ccd4d442cb350d3c5dec8537cfa01'; export const DELEGATION_TRANSACTION = - '0xf8fc278801640639091a26ce40f85ef85c941e02b2953adefec225cf0ec49805b1146a4429c180b84408bbb8240000000000000000000000000000000000000000000000000000000000003d2e00000000000000000000000000563ec3cafbbe7e60b04b3190e6eca66579706d8180830464b080830d8b05c101b8821a3cca8e8339456c6055ef796e5d716dda00de45f4cd9431bedf2119ae5de01b1f0a7268690784ba8f5c22b3043d0530ece5303a813ffdd9c0a5ae0ae85deee400b04543d6874f30eca88b3efb927c44934e9eb64a6f2327cce44a0a94faaca13615d153e804ba3fdd02bf5f8e1b6bc8e0f6149a1c7694803ed4fbb549bb79066101'; + '0xf9010327880166f0952a071e2a820190f85ef85c941e02b2953adefec225cf0ec49805b1146a4429c180b84408bbb8240000000000000000000000000000000000000000000000000000000000003d4500000000000000000000000000000021cbe0de65ea10f7658effeea70727154a81808303b46c8088f341b4c6b5ff5294c101b8829f5d674b8b043e95907b544b16a2c399bc04c7ebfcb9fa49956e050c59bbd5a510c57c6c135a3f03b12fe388409216809124a06d3c4ce797b5d97c5cd899d73d01dc4b66e63aade59b67639e44caedd5783a8e4d4f3c224f911acc5f43c387b54d22b2332172363a48e186d3bd52abec67d84e3d9e8c9696b241d29810a4e14e5801'; + +export const VALIDATOR_REGISTRATION_TRANSACTION = + '0xf8fb2788016754d8d0e8099340f85ef85c9400000000000000000000000000005374616b657280b844c3c4b13800000000000000000000000059b67d37be55997c96ea6f1890f08f825a41203c000000000000000000000000000000000000000000000000000000000000ec40818082b4ae808301bc4ec101b882ad3cef39b277cdb926f50ac04cbf11a68cf4a30b13311605978d57d92e92ee171a8bb734676cad0f7cdcdd935cf78b71b02e2193390ab752b032a6219131bf230119acb7f53ca1cf868a05f5c7b841e7052c8d779f876e6a6a6e5aa3daf0cd82dc6842d5fa276d4abc950b686e3d685261cae290dd20a13223a832e1b88be9ff0500'; export const EXIT_DELEGATION_TRANSACTION = '0xf8db278801640bf461bc7e1840f83df83b941e02b2953adefec225cf0ec49805b1146a4429c180a469e79b7d0000000000000000000000000000000000000000000000000000000000003d2d81808303525f808305f65ac101b8820cb393317793011b0a205973c77761f5c5c8652c21fe0115f527d2e2f2c1b5fc72a048107b263764312e9323f2ace9f30ce0beed873d7ef7f5432943330d2d5000a4a5f6439503f235ac6a5e17b47ac26c9e0c9e3be9dbd4cec3266fea324eb9bf5f806cedca59ff4144deb0ca18c41d9d6d600a86bf3d4e7b930bcec9b04c2e7301'; @@ -218,11 +221,17 @@ export const VALID_NFT_CONTRACT_DATA = export const DELEGATE_CLAUSE_DATA = '0x08bbb8240000000000000000000000000000000000000000000000000000000000003d45000000000000000000000000ae99cb89767a09d53e589a40cb4016974aba4b94'; +export const VALIDATOR_REGISTRATION_CLAUSE_DATA = + '0xc3c4b13800000000000000000000000059b67d37be55997c96ea6f1890f08f825a41203c000000000000000000000000000000000000000000000000000000000003f480'; export const DELEGATE_TOKEN_ID = '15685'; +export const STAKING_PERIOD = 259200; export const DELEGATE_VALIDATOR = '0xae99cb89767a09d53e589a40cb4016974aba4b94'; +export const VALIDATOR_REGISTRATION_VALIDATOR = '0x59b67d37be55997c96ea6f1890f08f825a41203c'; export const SIGNED_DELEGATE_RAW_HEX = '0xf8fa278801671187de8af70440f85ef85c941e02b2953adefec225cf0ec49805b1146a4429c180b84408bbb8240000000000000000000000000000000000000000000000000000000000003d2d00000000000000000000000000563ec3cafbbe7e60b04b3190e6eca66579706d818082cb5680820190c101b882313e783169eae670b1210e65f59b7c30cfd6c140c377257413fef378f70549f738a22a172ccc4ac7854ebc9952845fa90f7a676e5e9d9e277724ef3968e32e8a00f05ddf913b2b8fb884dbd5d5133217666ec4a68d077dce1537d270f25caf440e0f11232ecc4a5859ab49b2442cc0b0c0c1488302556e6323eec0498f1a42e17301'; export const SIGNED_DELEGATE_RAW_EXPECTED_TOKEN_ID = '15661'; export const SIGNED_DELEGATE_RAW_EXPECTED_VALIDATOR = '0x00563ec3cafbbe7e60b04b3190e6eca66579706d'; export const SIGNED_DELEGATE_RAW_EXPECTED_STAKING_CONTRACT = '0x1e02b2953adefec225cf0ec49805b1146a4429c1'; +export const VALIDATOR_REGISTRATION_STAKER_CONTRACT = '0x00000000000000000000000000005374616b6572'; +export const VALIDATOR_REGISTRATION_STAKING_PERIOD = 60480; diff --git a/modules/sdk-coin-vet/test/transactionBuilder/validatorRegistrationTxnBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/validatorRegistrationTxnBuilder.ts new file mode 100644 index 0000000000..786bd9bbfb --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/validatorRegistrationTxnBuilder.ts @@ -0,0 +1,197 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, ValidatorRegistrationTransaction } from '../../src/lib'; +import should from 'should'; +import { + ADD_VALIDATION_METHOD_ID, + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET, +} from '../../src/lib/constants'; +import EthereumAbi from 'ethereumjs-abi'; +import * as testData from '../resources/vet'; +import { BN } from 'ethereumjs-util'; +import utils from '../../src/lib/utils'; + +describe('VET Validator Registration Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const stakingPeriod = 60480; + const validatorAddress = '0x9a7aFCACc88c106f3bbD6B213CD0821D9224d945'; + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getValidatorRegistrationBuilder(); + 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 validator registration transaction', async function () { + const txBuilder = factory.getValidatorRegistrationBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.stakingPeriod(stakingPeriod); + 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(ValidatorRegistrationTransaction); + + const validatorRegistrationTransaction = tx as ValidatorRegistrationTransaction; + validatorRegistrationTransaction.stakingContractAddress.should.equal( + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET + ); + validatorRegistrationTransaction.stakingPeriod.should.equal(stakingPeriod); + validatorRegistrationTransaction.validator.should.equal(validatorAddress); + + // Verify clauses + validatorRegistrationTransaction.clauses.length.should.equal(1); + should.exist(validatorRegistrationTransaction.clauses[0].to); + validatorRegistrationTransaction.clauses[0].to?.should.equal( + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET + ); + + // Verify transaction data is correctly encoded using ethereumABI + should.exist(validatorRegistrationTransaction.clauses[0].data); + const txData = validatorRegistrationTransaction.clauses[0].data; + txData.should.startWith(ADD_VALIDATION_METHOD_ID); + + // Verify the encoded data matches what we expect from ethereumABI + const methodName = 'addValidation'; + const types = ['address', 'uint32']; + const params = [validatorAddress, new BN(stakingPeriod)]; + + 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 + validatorRegistrationTransaction.recipients.length.should.equal(1); + validatorRegistrationTransaction.recipients[0].address.should.equal( + VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET + ); + }); + + describe('Failure scenarios', function () { + it('should throw error when stakingContractAddress is missing', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.stakingPeriod(stakingPeriod); + txBuilder.validator(validatorAddress); + + await txBuilder.build().should.be.rejectedWith('Staking contract address is required'); + }); + + it('should throw error when stakingPeriod 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 period 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.stakingPeriod(stakingPeriod); + + await txBuilder.build().should.be.rejectedWith('Validator address 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.getValidatorRegistrationBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.stakingPeriod(stakingPeriod); + 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(ValidatorRegistrationTransaction); + + const validatorRegistrationTransaction = tx as ValidatorRegistrationTransaction; + // Verify the transaction has inputs but with undefined address + validatorRegistrationTransaction.inputs.length.should.equal(1); + should.not.exist(validatorRegistrationTransaction.inputs[0].address); + + // Verify the transaction has the correct output + validatorRegistrationTransaction.outputs.length.should.equal(1); + validatorRegistrationTransaction.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.getValidatorRegistrationBuilder(); + txBuilder.stakingContractAddress(VALIDATOR_REGISTRATION_STAKER_CONTRACT_ADDRESS_TESTNET); + txBuilder.stakingPeriod(stakingPeriod); + // Not setting chainTag + txBuilder.blockRef('0x0000000000000000'); + 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(ValidatorRegistrationTransaction); + + const validatorRegistrationTransaction = tx as ValidatorRegistrationTransaction; + // Verify the chainTag is set to the testnet default (39) + validatorRegistrationTransaction.chainTag.should.equal(39); + }); + }); + + describe('decodeAddValidationData', function () { + it('should correctly decode validator registration transaction data with proper address formatting', function () { + const decodedData = utils.decodeAddValidationData(testData.VALIDATOR_REGISTRATION_CLAUSE_DATA); + + decodedData.period.should.equal(testData.STAKING_PERIOD); + decodedData.validator.should.equal(testData.VALIDATOR_REGISTRATION_VALIDATOR); + decodedData.validator.should.startWith('0x'); + decodedData.validator.should.equal(decodedData.validator.toLowerCase()); + }); + + it('should correctly deserialize real signed validator registration transaction from raw hex', async function () { + const txBuilder = factory.from(testData.VALIDATOR_REGISTRATION_TRANSACTION); + const tx = txBuilder.transaction as ValidatorRegistrationTransaction; + tx.should.be.instanceof(ValidatorRegistrationTransaction); + tx.stakingContractAddress.should.equal(testData.VALIDATOR_REGISTRATION_STAKER_CONTRACT); + tx.stakingPeriod.should.equal(testData.VALIDATOR_REGISTRATION_STAKING_PERIOD); + tx.validator.should.equal(testData.VALIDATOR_REGISTRATION_VALIDATOR); + tx.validator.should.startWith('0x'); + tx.validator.should.equal(tx.validator.toLowerCase()); + should.exist(tx.inputs); + tx.inputs.length.should.equal(1); + + const decodedData = utils.decodeAddValidationData(tx.clauses[0].data); + decodedData.period.should.equal(testData.VALIDATOR_REGISTRATION_STAKING_PERIOD); + decodedData.validator.should.equal(testData.VALIDATOR_REGISTRATION_VALIDATOR); + }); + }); +});