diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index aabf047108..0a82d04818 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -7,6 +7,8 @@ export const STAKING_METHOD_ID = '0xa694fc3a'; export const EXIT_DELEGATION_METHOD_ID = '0x3fb7a871'; export const BURN_NFT_METHOD_ID = '0x42966c68'; export const TRANSFER_NFT_METHOD_ID = '0x23b872dd'; +export const CLAIM_BASE_REWARDS_METHOD_ID = '0x037402d3'; +export const CLAIM_STAKING_REWARDS_METHOD_ID = '0xeb2767fa'; export const STARGATE_NFT_ADDRESS = '0x1856c533ac2d94340aaa8544d35a5c1d4a21dee7'; export const STARGATE_DELEGATION_ADDRESS = '0x4cb1c9ef05b529c093371264fab2c93cc6cddb0e'; diff --git a/modules/sdk-coin-vet/src/lib/iface.ts b/modules/sdk-coin-vet/src/lib/iface.ts index b011b08ae0..27280a3c04 100644 --- a/modules/sdk-coin-vet/src/lib/iface.ts +++ b/modules/sdk-coin-vet/src/lib/iface.ts @@ -3,6 +3,7 @@ import { TransactionType as BitGoTransactionType, TransactionRecipient, } from '@bitgo/sdk-core'; +import { ClaimRewardsData } from './types'; /** * The transaction data returned from the toJson() function of a transaction @@ -28,9 +29,11 @@ export interface VetTransactionData { stakingContractAddress?: string; amountToStake?: string; nftCollectionId?: string; + claimRewardsData?: ClaimRewardsData; } export interface VetTransactionExplanation extends BaseTransactionExplanation { sender?: string; type?: BitGoTransactionType; + claimRewardsData?: ClaimRewardsData; } diff --git a/modules/sdk-coin-vet/src/lib/index.ts b/modules/sdk-coin-vet/src/lib/index.ts index c427f61a28..6c7b73aec6 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -8,6 +8,9 @@ export { AddressInitializationTransaction } from './transaction/addressInitializ export { FlushTokenTransaction } from './transaction/flushTokenTransaction'; export { TokenTransaction } from './transaction/tokenTransaction'; export { StakingTransaction } from './transaction/stakingTransaction'; +export { ExitDelegationTransaction } from './transaction/exitDelegation'; +export { BurnNftTransaction } from './transaction/burnNftTransaction'; +export { ClaimRewardsTransaction } from './transaction/claimRewards'; export { NFTTransaction } from './transaction/nftTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; @@ -17,5 +20,6 @@ export { StakingBuilder } from './transactionBuilder/stakingBuilder'; export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder'; export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; +export { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { Constants, Utils, Interface }; diff --git a/modules/sdk-coin-vet/src/lib/transaction/claimRewards.ts b/modules/sdk-coin-vet/src/lib/transaction/claimRewards.ts new file mode 100644 index 0000000000..14b2f4e190 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/claimRewards.ts @@ -0,0 +1,294 @@ +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; +import { TransactionType, InvalidTransactionError, TransactionRecipient } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1, TransactionClause } from '@vechain/sdk-core'; +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; +import { ClaimRewardsData } from '../types'; +import { + CLAIM_BASE_REWARDS_METHOD_ID, + CLAIM_STAKING_REWARDS_METHOD_ID, + STARGATE_DELEGATION_ADDRESS, +} from '../constants'; + +export class ClaimRewardsTransaction extends Transaction { + private _claimRewardsData: ClaimRewardsData; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingClaim; + } + + get claimRewardsData(): ClaimRewardsData { + return this._claimRewardsData; + } + + set claimRewardsData(data: ClaimRewardsData) { + this._claimRewardsData = data; + } + + /** @inheritdoc */ + async build(): Promise { + this.buildClauses(); + await this.buildRawTransaction(); + this.generateTxnIdAndSetSender(); + this.loadInputsAndOutputs(); + } + + get clauses(): TransactionClause[] { + return this._clauses; + } + + set clauses(clauses: TransactionClause[]) { + this._clauses = clauses; + } + + get recipients(): TransactionRecipient[] { + return this._recipients; + } + + set recipients(recipients: TransactionRecipient[]) { + this._recipients = recipients; + } + + /** @inheritdoc */ + buildClauses(): void { + if (!this._claimRewardsData) { + throw new InvalidTransactionError('Missing claim rewards data'); + } + + const clauses: TransactionClause[] = []; + + // Add clause for claiming base rewards if requested + const shouldClaimBaseRewards = this.claimRewardsData.claimBaseRewards !== false; // Default true + if (shouldClaimBaseRewards) { + clauses.push(this.buildClaimBaseRewardsClause()); + } + + // Add clause for claiming staking rewards if requested + const shouldClaimStakingRewards = this.claimRewardsData.claimStakingRewards !== false; // Default true + if (shouldClaimStakingRewards) { + clauses.push(this.buildClaimStakingRewardsClause()); + } + + if (clauses.length === 0) { + throw new InvalidTransactionError('At least one type of rewards must be claimed'); + } + + this.clauses = clauses; + + // Set recipients as empty since claim rewards doesn't send value + this.recipients = []; + } + + /** + * Get the delegation contract address to use for claims + * Uses the address from claimRewardsData if provided, otherwise falls back to default + */ + private getDelegationAddress(): string { + return this._claimRewardsData.delegationContractAddress || STARGATE_DELEGATION_ADDRESS; + } + + /** + * Build clause for claiming base rewards + */ + private buildClaimBaseRewardsClause(): TransactionClause { + const methodData = this.encodeClaimRewardsMethod( + CLAIM_BASE_REWARDS_METHOD_ID, + this._claimRewardsData.validatorAddress, + this._claimRewardsData.delegatorAddress + ); + + return { + to: this.getDelegationAddress(), + value: '0x0', + data: methodData, + }; + } + + /** + * Build clause for claiming staking rewards + */ + private buildClaimStakingRewardsClause(): TransactionClause { + const methodData = this.encodeClaimRewardsMethod( + CLAIM_STAKING_REWARDS_METHOD_ID, + this._claimRewardsData.validatorAddress, + this._claimRewardsData.delegatorAddress + ); + + return { + to: this.getDelegationAddress(), + value: '0x0', + data: methodData, + }; + } + + /** + * Encode the claim rewards method call data + */ + private encodeClaimRewardsMethod(methodId: string, validatorAddress: string, delegatorAddress: string): string { + const methodName = methodId === CLAIM_BASE_REWARDS_METHOD_ID ? 'claimBaseRewards' : 'claimStakingRewards'; + const types = ['address', 'address']; + const params = [validatorAddress, delegatorAddress]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + /** @inheritdoc */ + 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, + sender: this.sender, + feePayer: this.feePayerAddress, + recipients: this.recipients, + claimRewardsData: this._claimRewardsData, + }; + return json; + } + + /** @inheritdoc */ + 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); + + // Parse claim rewards data from clauses + this.parseClaimRewardsDataFromClauses(body.clauses); + + // Set recipients as empty for claim rewards + this.recipients = []; + 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}`); + } + } + + /** + * Parse claim rewards data from transaction clauses + */ + private parseClaimRewardsDataFromClauses(clauses: TransactionClause[]): void { + if (!clauses || clauses.length === 0) { + throw new InvalidTransactionError('No clauses found in transaction'); + } + + let claimBaseRewards = false; + let claimStakingRewards = false; + let validatorAddress = ''; + let delegatorAddress = ''; + let delegationContractAddress = ''; + + for (const clause of clauses) { + // Check if this is a claim rewards clause by looking at the method ID in data + if ( + clause.data && + (clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID) || + clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) + ) { + // Store the contract address (could be different from default) + if (!delegationContractAddress) { + delegationContractAddress = clause.to || ''; + } + + if (clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID)) { + claimBaseRewards = true; + if (!validatorAddress || !delegatorAddress) { + const addresses = this.parseAddressesFromClaimData(clause.data); + validatorAddress = addresses.validator; + delegatorAddress = addresses.delegator; + } + } else if (clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) { + claimStakingRewards = true; + if (!validatorAddress || !delegatorAddress) { + const addresses = this.parseAddressesFromClaimData(clause.data); + validatorAddress = addresses.validator; + delegatorAddress = addresses.delegator; + } + } + } + } + + if (!claimBaseRewards && !claimStakingRewards) { + throw new InvalidTransactionError('Transaction does not contain claim rewards clauses'); + } + + this._claimRewardsData = { + validatorAddress, + delegatorAddress, + delegationContractAddress: + delegationContractAddress !== STARGATE_DELEGATION_ADDRESS ? delegationContractAddress : undefined, + claimBaseRewards, + claimStakingRewards, + }; + } + + /** + * Parse validator and delegator addresses from claim rewards method data. + * + * The method data follows Ethereum ABI encoding where each parameter occupies 32 bytes (64 hex chars). + * After the 4-byte method ID, the parameters are laid out as: + * - Bytes 0-31 (chars 0-63): First address parameter (validator) - right-padded, actual address in bytes 12-31 + * - Bytes 32-63 (chars 64-127): Second address parameter (delegator) - right-padded, actual address in bytes 44-63 + * + * @param data The encoded method call data including method ID and parameters + * @returns Object containing the extracted validator and delegator addresses + */ + private parseAddressesFromClaimData(data: string): { validator: string; delegator: string } { + // Remove method ID (first 10 characters: '0x' + 4-byte method ID) + const methodData = data.slice(10); + + // Extract validator address from first parameter (bytes 12-31 of first 32-byte slot) + // Slice 24-64: Skip first 12 bytes of padding (24 hex chars), take next 20 bytes (40 hex chars) + const validatorAddress = '0x' + methodData.slice(24, 64); + + // Extract delegator address from second parameter (bytes 44-63 of second 32-byte slot) + // Slice 88-128: Skip to second slot + 12 bytes padding (88 hex chars), take next 20 bytes (40 hex chars) + const delegatorAddress = '0x' + methodData.slice(88, 128); + + return { + validator: validatorAddress, + delegator: delegatorAddress, + }; + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/claimRewardsBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/claimRewardsBuilder.ts new file mode 100644 index 0000000000..82c1de51ad --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/claimRewardsBuilder.ts @@ -0,0 +1,171 @@ +import { TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionClause } from '@vechain/sdk-core'; +import assert from 'assert'; + +import { TransactionBuilder } from './transactionBuilder'; +import { ClaimRewardsTransaction } from '../transaction/claimRewards'; +import { Transaction } from '../transaction/transaction'; +import { ClaimRewardsData } from '../types'; +import { + CLAIM_BASE_REWARDS_METHOD_ID, + CLAIM_STAKING_REWARDS_METHOD_ID, + STARGATE_DELEGATION_ADDRESS, +} from '../constants'; +import utils from '../utils'; + +export class ClaimRewardsBuilder extends TransactionBuilder { + /** + * Creates a new ClaimRewardsBuilder instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new ClaimRewardsTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing ClaimRewardsTransaction. + * + * @param {ClaimRewardsTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: ClaimRewardsTransaction): void { + this._transaction = tx; + } + + /** + * Gets the claim rewards transaction instance. + * + * @returns {ClaimRewardsTransaction} The claim rewards transaction + */ + get claimRewardsTransaction(): ClaimRewardsTransaction { + return this._transaction as ClaimRewardsTransaction; + } + + /** + * Gets the transaction type for claim rewards. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingClaim; + } + + /** + * Validates the transaction clauses for claim rewards 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; + } + + let hasValidClaimClause = false; + + for (const clause of clauses) { + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + // For claim rewards transactions, value must be '0x0' or 0 + if (clause.value !== '0x0' && clause.value !== 0 && clause.value !== '0') { + return false; + } + + // Check if the clause is for claim rewards operations + if (clause.to.toLowerCase() === STARGATE_DELEGATION_ADDRESS.toLowerCase() && clause.data) { + if ( + clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID) || + clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID) + ) { + hasValidClaimClause = true; + } + } + } + + return hasValidClaimClause; + } catch (e) { + return false; + } + } + + /** + * Sets the claim rewards data for this transaction. + * + * @param {ClaimRewardsData} data - The claim rewards data + * @returns {ClaimRewardsBuilder} This transaction builder + */ + claimRewardsData(data: ClaimRewardsData): this { + this.validateClaimRewardsData(data); + this.claimRewardsTransaction.claimRewardsData = data; + return this; + } + + /** + * Validates the claim rewards data. + * + * @param {ClaimRewardsData} data - The claim rewards data to validate + */ + private validateClaimRewardsData(data: ClaimRewardsData): void { + if (!data) { + throw new Error('Claim rewards data is required'); + } + + if (!data.validatorAddress) { + throw new Error('Validator address is required'); + } + + if (!data.delegatorAddress) { + throw new Error('Delegator address is required'); + } + + if (!utils.isValidAddress(data.validatorAddress)) { + throw new Error('Invalid validator address format'); + } + + if (!utils.isValidAddress(data.delegatorAddress)) { + throw new Error('Invalid delegator address format'); + } + + if (data.claimBaseRewards !== undefined && typeof data.claimBaseRewards !== 'boolean') { + throw new Error('claimBaseRewards must be a boolean'); + } + + if (data.claimStakingRewards !== undefined && typeof data.claimStakingRewards !== 'boolean') { + throw new Error('claimStakingRewards must be a boolean'); + } + + // At least one type of rewards must be claimed (both default to true if undefined) + const claimBase = data.claimBaseRewards !== false; + const claimStaking = data.claimStakingRewards !== false; + + if (!claimBase && !claimStaking) { + throw new Error('At least one type of rewards (base or staking) must be claimed'); + } + } + + /** @inheritdoc */ + validateTransaction(transaction?: ClaimRewardsTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + + const claimData = transaction.claimRewardsData; + assert(claimData, 'Claim rewards data is required'); + assert(claimData.validatorAddress, 'Validator address is required'); + assert(claimData.delegatorAddress, 'Delegator address is required'); + + this.validateAddress({ address: claimData.validatorAddress }); + this.validateAddress({ address: claimData.delegatorAddress }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.claimRewardsTransaction.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 60a3558bef..84d3c22b54 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -7,12 +7,14 @@ import { AddressInitializationBuilder } from './transactionBuilder/addressInitia import { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; import { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; import { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; +import { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; import { Transaction } from './transaction/transaction'; import utils from './utils'; import { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; import { FlushTokenTransaction } from './transaction/flushTokenTransaction'; import { ExitDelegationTransaction } from './transaction/exitDelegation'; import { BurnNftTransaction } from './transaction/burnNftTransaction'; +import { ClaimRewardsTransaction } from './transaction/claimRewards'; import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBuilder'; import { TokenTransaction } from './transaction/tokenTransaction'; import { StakingBuilder } from './transactionBuilder/stakingBuilder'; @@ -63,6 +65,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const burnNftTx = new BurnNftTransaction(this._coinConfig); burnNftTx.fromDeserializedSignedTransaction(signedTx); return this.getBurnNftBuilder(burnNftTx); + case TransactionType.StakingClaim: + const claimRewardsTx = new ClaimRewardsTransaction(this._coinConfig); + claimRewardsTx.fromDeserializedSignedTransaction(signedTx); + return this.getClaimRewardsBuilder(claimRewardsTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -122,6 +128,16 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new BurnNftBuilder(this._coinConfig)); } + /** + * Gets a claim rewards transaction builder. + * + * @param {ClaimRewardsTransaction} tx - The claim rewards transaction to use + * @returns {ClaimRewardsBuilder} The claim rewards transaction builder + */ + getClaimRewardsBuilder(tx?: ClaimRewardsTransaction): ClaimRewardsBuilder { + return this.initializeBuilder(tx, new ClaimRewardsBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-vet/src/lib/types.ts b/modules/sdk-coin-vet/src/lib/types.ts index 5fe2eaac31..9a8eb2bb0d 100644 --- a/modules/sdk-coin-vet/src/lib/types.ts +++ b/modules/sdk-coin-vet/src/lib/types.ts @@ -7,3 +7,11 @@ export interface ExplainTransactionOptions { export interface VetParseTransactionOptions extends ParseTransactionOptions { txHex: string; } + +export interface ClaimRewardsData { + validatorAddress: string; + delegatorAddress: string; + delegationContractAddress?: string; + claimBaseRewards?: boolean; + claimStakingRewards?: boolean; +} diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index d6efab9375..3c6187d1ac 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -85,9 +85,9 @@ export class Utils implements BaseUtils { } 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 + return TransactionType.StakingUnlock; } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { - return TransactionType.StakingWithdraw; // Using StakingWithdraw for burn NFT + return TransactionType.StakingWithdraw; } else if (clauses[0].data.startsWith(TRANSFER_NFT_METHOD_ID)) { return TransactionType.SendNFT; } else { diff --git a/modules/sdk-coin-vet/test/transactionBuilder/claimRewardsBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/claimRewardsBuilder.ts new file mode 100644 index 0000000000..c89eca059e --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/claimRewardsBuilder.ts @@ -0,0 +1,307 @@ +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction, ClaimRewardsTransaction } from '../../src/lib'; +import { ClaimRewardsData } from '../../src/lib/types'; +import should from 'should'; +import { + CLAIM_BASE_REWARDS_METHOD_ID, + CLAIM_STAKING_REWARDS_METHOD_ID, + STARGATE_DELEGATION_ADDRESS, +} from '../../src/lib/constants'; +import { TransactionType } from '@bitgo/sdk-core'; + +describe('VET Claim Rewards Transaction', function () { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + const validatorAddress = '0x7567d83b7b8d80addcb281a71d54fc7b3364ffed'; + const delegatorAddress = '0x625476eab2e75c5b9c6f8a9d7f1b2c5e6f8e9a7b'; + + // Helper function to create a basic transaction builder with common properties + const createBasicTxBuilder = () => { + const txBuilder = factory.getClaimRewardsBuilder(); + 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 claim rewards transaction with both reward types', async function () { + const txBuilder = factory.getClaimRewardsBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimBaseRewards: true, + claimStakingRewards: true, + }); + 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(ClaimRewardsTransaction); + + const claimTx = tx as ClaimRewardsTransaction; + claimTx.claimRewardsData.validatorAddress.should.equal(validatorAddress); + claimTx.claimRewardsData.delegatorAddress.should.equal(delegatorAddress); + claimTx.claimRewardsData.claimBaseRewards?.should.be.true(); + claimTx.claimRewardsData.claimStakingRewards?.should.be.true(); + + // Verify clauses - should have 2 clauses for both reward types + claimTx.clauses.length.should.equal(2); + claimTx.clauses.forEach((clause) => { + should.exist(clause.to); + clause.to?.should.equal(STARGATE_DELEGATION_ADDRESS); + clause.value.should.equal('0x0'); + should.exist(clause.data); + }); + + // Verify method IDs + const hasBaseRewards = claimTx.clauses.some((clause) => clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID)); + const hasStakingRewards = claimTx.clauses.some((clause) => clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)); + + hasBaseRewards.should.be.true(); + hasStakingRewards.should.be.true(); + + // Verify recipients should be empty for claim rewards + claimTx.recipients.length.should.equal(0); + }); + + it('should build a claim rewards transaction with only base rewards', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimBaseRewards: true, + claimStakingRewards: false, + }); + + const tx = await txBuilder.build(); + const claimTx = tx as ClaimRewardsTransaction; + + // Should have only 1 clause for base rewards + claimTx.clauses.length.should.equal(1); + claimTx.clauses[0].data.should.startWith(CLAIM_BASE_REWARDS_METHOD_ID); + claimTx.clauses[0].to?.should.equal(STARGATE_DELEGATION_ADDRESS); + claimTx.clauses[0].value.should.equal('0x0'); + + claimTx.claimRewardsData.claimBaseRewards?.should.be.true(); + claimTx.claimRewardsData.claimStakingRewards?.should.be.false(); + }); + + it('should build a claim rewards transaction with only staking rewards', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimBaseRewards: false, + claimStakingRewards: true, + }); + + const tx = await txBuilder.build(); + const claimTx = tx as ClaimRewardsTransaction; + + // Should have only 1 clause for staking rewards + claimTx.clauses.length.should.equal(1); + claimTx.clauses[0].data.should.startWith(CLAIM_STAKING_REWARDS_METHOD_ID); + claimTx.clauses[0].to?.should.equal(STARGATE_DELEGATION_ADDRESS); + claimTx.clauses[0].value.should.equal('0x0'); + + claimTx.claimRewardsData.claimBaseRewards?.should.be.false(); + claimTx.claimRewardsData.claimStakingRewards?.should.be.true(); + }); + + describe('Failure scenarios', function () { + it('should throw error when claim rewards data is missing', async function () { + const txBuilder = createBasicTxBuilder(); + // Not setting claimRewardsData + + await txBuilder.build().should.be.rejectedWith('Claim rewards data is required'); + }); + + it('should throw error when validator address is missing', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + delegatorAddress, + } as Partial as ClaimRewardsData); + }).throw('Validator address is required'); + }); + + it('should throw error when delegator address is missing', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress, + } as Partial as ClaimRewardsData); + }).throw('Delegator address is required'); + }); + + it('should throw error when validator address is invalid', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress: 'invalid-address', + delegatorAddress, + }); + }).throw(/Invalid validator address format/); + }); + + it('should throw error when delegator address is invalid', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress: '0xinvalid', + }); + }).throw(/Invalid delegator address format/); + }); + + it('should throw error when both reward flags are false', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimBaseRewards: false, + claimStakingRewards: false, + }); + }).throw('At least one type of rewards (base or staking) must be claimed'); + }); + + it('should throw error when claimBaseRewards is not boolean', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimBaseRewards: 'true' as unknown as boolean, + }); + }).throw('claimBaseRewards must be a boolean'); + }); + + it('should throw error when claimStakingRewards is not boolean', async function () { + const txBuilder = createBasicTxBuilder(); + + should(() => { + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + claimStakingRewards: 1 as unknown as boolean, + }); + }).throw('claimStakingRewards must be a boolean'); + }); + + it('should default to claiming both rewards when flags are undefined', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + }); + + const tx = await txBuilder.build(); + const claimTx = tx as ClaimRewardsTransaction; + + // Should have 2 clauses by default + claimTx.clauses.length.should.equal(2); + + const hasBaseRewards = claimTx.clauses.some((clause) => clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID)); + const hasStakingRewards = claimTx.clauses.some((clause) => + clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID) + ); + + hasBaseRewards.should.be.true(); + hasStakingRewards.should.be.true(); + }); + + it('should build transaction with undefined sender but include it in inputs', async function () { + const txBuilder = factory.getClaimRewardsBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + }); + 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(ClaimRewardsTransaction); + + const claimTx = tx as ClaimRewardsTransaction; + // Verify the transaction has inputs but with undefined address + claimTx.inputs.length.should.equal(1); + should.not.exist(claimTx.inputs[0].address); + + // Verify the transaction has no outputs (claim rewards doesn't transfer value) + claimTx.outputs.length.should.equal(0); + }); + + it('should use network default chainTag when not explicitly set', async function () { + const txBuilder = factory.getClaimRewardsBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + }); + // 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(ClaimRewardsTransaction); + + const claimTx = tx as ClaimRewardsTransaction; + // Verify the chainTag is set to the testnet default (39) + claimTx.chainTag.should.equal(39); + }); + + it('should serialize and explain transaction correctly', async function () { + const txBuilder = createBasicTxBuilder(); + txBuilder.claimRewardsData({ + validatorAddress, + delegatorAddress, + }); + + const tx = await txBuilder.build(); + const claimTx = tx as ClaimRewardsTransaction; + + // Test serialization + const serialized = claimTx.toBroadcastFormat(); + serialized.should.be.String(); + serialized.should.startWith('0x'); + + // Test explanation + const explanation = claimTx.explainTransaction(); + explanation.type?.should.equal(TransactionType.StakingClaim); + should.exist(explanation.fee); + explanation.outputAmount.should.equal('0'); + explanation.changeAmount.should.equal('0'); + + // Test toJson + const json = claimTx.toJson(); + should.exist(json.claimRewardsData); + json.claimRewardsData?.validatorAddress.should.equal(validatorAddress); + json.claimRewardsData?.delegatorAddress.should.equal(delegatorAddress); + }); + }); +});