Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion modules/sdk-coin-vet/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export interface VetTransactionData {
nftTokenId?: number; // Used as tier level (levelId) for stakeAndDelegate method (not the actual NFT token ID)
autorenew?: boolean; // Autorenew flag for stakeAndDelegate method
nftCollectionId?: string;
claimRewardsData?: ClaimRewardsData;
validatorAddress?: string;
}

Expand Down
152 changes: 42 additions & 110 deletions modules/sdk-coin-vet/src/lib/transaction/claimRewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,32 @@ 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 } from '../constants';
import { CLAIM_STAKING_REWARDS_METHOD_ID } from '../constants';
import utils from '../utils';

export class ClaimRewardsTransaction extends Transaction {
private _claimRewardsData: ClaimRewardsData;
private _stakingContractAddress: string;
private _tokenId: string;

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
this._type = TransactionType.StakingClaim;
}

get claimRewardsData(): ClaimRewardsData {
return this._claimRewardsData;
get stakingContractAddress(): string {
return this._stakingContractAddress;
}

set claimRewardsData(data: ClaimRewardsData) {
this._claimRewardsData = data;
set stakingContractAddress(address: string) {
this._stakingContractAddress = address;
}

get tokenId(): string {
return this._tokenId;
}

set tokenId(tokenId: string) {
this._tokenId = tokenId;
}

/** @inheritdoc */
Expand Down Expand Up @@ -51,65 +59,37 @@ export class ClaimRewardsTransaction extends Transaction {

/** @inheritdoc */
buildClauses(): void {
if (!this._claimRewardsData) {
throw new InvalidTransactionError('Missing claim rewards data');
if (!this.stakingContractAddress) {
throw new Error('Staking contract address is not set');
}

const clauses: TransactionClause[] = [];
utils.validateStakingContractAddress(this.stakingContractAddress, this._coinConfig);

// Add clause for claiming base rewards if requested
const shouldClaimBaseRewards = this.claimRewardsData.claimBaseRewards !== false; // Default true
if (shouldClaimBaseRewards) {
clauses.push(this.buildClaimBaseRewardsClause());
if (this.tokenId === undefined || this.tokenId === null) {
throw new Error('Token ID is not set');
}

// Add clause for claiming staking rewards if requested
const shouldClaimStakingRewards = this.claimRewardsData.claimStakingRewards !== false; // Default true
if (shouldClaimStakingRewards) {
clauses.push(this.buildClaimStakingRewardsClause());
}
const data = this.encodeClaimRewardsMethod(this.tokenId);
this._transactionData = data;

if (clauses.length === 0) {
throw new InvalidTransactionError('At least one type of rewards must be claimed');
}

this.clauses = clauses;
// Create the clause for claim rewards
this._clauses = [
{
to: this.stakingContractAddress,
value: '0x0',
data: this._transactionData,
},
];

// Set recipients as empty since claim rewards doesn't send value
this.recipients = [];
}

/**
* Build clause for claiming base rewards (claimVetGeneratedVtho)
*/
private buildClaimBaseRewardsClause(): TransactionClause {
const methodData = this.encodeClaimRewardsMethod(CLAIM_BASE_REWARDS_METHOD_ID, this._claimRewardsData.tokenId);

return {
to: utils.getDefaultStakingAddress(this._coinConfig),
value: '0x0',
data: methodData,
};
}

/**
* Build clause for claiming staking rewards (claimRewards)
*/
private buildClaimStakingRewardsClause(): TransactionClause {
const methodData = this.encodeClaimRewardsMethod(CLAIM_STAKING_REWARDS_METHOD_ID, this._claimRewardsData.tokenId);

return {
to: utils.getDefaultDelegationAddress(this._coinConfig),
value: '0x0',
data: methodData,
};
}

/**
* Encode the claim rewards method call data
*/
private encodeClaimRewardsMethod(methodId: string, tokenId: string): string {
const methodName = methodId === CLAIM_BASE_REWARDS_METHOD_ID ? 'claimVetGeneratedVtho' : 'claimRewards';
private encodeClaimRewardsMethod(tokenId: string): string {
const methodName = 'claimRewards';
const types = ['uint256'];
const params = [tokenId];

Expand All @@ -133,7 +113,8 @@ export class ClaimRewardsTransaction extends Transaction {
sender: this.sender,
feePayer: this.feePayerAddress,
recipients: this.recipients,
claimRewardsData: this._claimRewardsData,
tokenId: this.tokenId,
stakingContractAddress: this.stakingContractAddress,
};
return json;
}
Expand All @@ -159,8 +140,14 @@ export class ClaimRewardsTransaction extends Transaction {
this.dependsOn = body.dependsOn || null;
this.nonce = String(body.nonce);

// Parse claim rewards data from clauses
this.parseClaimRewardsDataFromClauses(body.clauses);
if (body.clauses.length === 1) {
const clause = body.clauses[0];
if (clause.data && clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) {
// claimRewards should go to STARGATE_DELEGATION_ADDRESS
this.tokenId = utils.decodeClaimRewardsData(clause.data);
this.stakingContractAddress = clause.to || '0x0';
}
}

// Set recipients as empty for claim rewards
this.recipients = [];
Expand All @@ -185,59 +172,4 @@ export class ClaimRewardsTransaction extends Transaction {
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 tokenId = '';
let delegationContractAddress = '';
let stargateNftAddress = '';

for (const clause of clauses) {
if (clause.data) {
if (clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID)) {
// claimVetGeneratedVtho should go to STARGATE_NFT_ADDRESS
claimBaseRewards = true;
if (!tokenId) {
tokenId = utils.decodeClaimRewardsData(clause.data);
}
if (!stargateNftAddress && clause.to) {
stargateNftAddress = clause.to;
}
} else if (clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) {
// claimRewards should go to STARGATE_DELEGATION_ADDRESS
claimStakingRewards = true;
if (!tokenId) {
tokenId = utils.decodeClaimRewardsData(clause.data);
}
if (!delegationContractAddress && clause.to) {
delegationContractAddress = clause.to;
}
}
}
}

if (!claimBaseRewards && !claimStakingRewards) {
throw new InvalidTransactionError('Transaction does not contain claim rewards clauses');
}

this._claimRewardsData = {
tokenId,
delegationContractAddress:
delegationContractAddress && !utils.isDelegationContractAddress(delegationContractAddress)
? delegationContractAddress
: undefined,
stargateNftAddress:
stargateNftAddress && !utils.isNftContractAddress(stargateNftAddress) ? stargateNftAddress : undefined,
claimBaseRewards,
claimStakingRewards,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class DelegateClauseTransaction extends Transaction {
throw new Error('Staking contract address is not set');
}

utils.validateDelegationContractAddress(this.stakingContractAddress, this._coinConfig);
utils.validateStakingContractAddress(this.stakingContractAddress, this._coinConfig);

if (this.tokenId === undefined || this.tokenId === null) {
throw new Error('Token ID is not set');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ 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 } from '../constants';
import { CLAIM_STAKING_REWARDS_METHOD_ID } from '../constants';
import utils from '../utils';

export class ClaimRewardsBuilder extends TransactionBuilder {
Expand Down Expand Up @@ -49,92 +48,61 @@ export class ClaimRewardsBuilder extends TransactionBuilder {
}

/**
* 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.
* Sets the staking contract address for this claim tx.
* The address must be explicitly provided to ensure the correct contract is used.
*
* @param {string} address - The staking contract address (required)
* @returns {ClaimRewardsBuilder} This transaction builder
* @throws {Error} If no address is provided
*/
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;
}

const isDelegationContract = utils.isDelegationContractAddress(clause.to);
const isNftContract = utils.isNftContractAddress(clause.to);

if (clause.data && (isDelegationContract || isNftContract)) {
if (
(clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID) && isNftContract) ||
(clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID) && isDelegationContract)
) {
hasValidClaimClause = true;
}
}
}

return hasValidClaimClause;
} catch (e) {
return false;
stakingContractAddress(address: string): this {
if (!address) {
throw new Error('Staking contract address is required');
}
this.validateAddress({ address });
this.claimRewardsTransaction.stakingContractAddress = address;
return this;
}

/**
* Sets the claim rewards data for this transaction.
* Sets the token ID for this claim tx.
*
* @param {ClaimRewardsData} data - The claim rewards data
* @returns {ClaimRewardsBuilder} This transaction builder
* @param {number} levelId - The NFT token ID
* @returns {DelegateTxnBuilder} This transaction builder
*/
claimRewardsData(data: ClaimRewardsData): this {
this.validateClaimRewardsData(data);
this.claimRewardsTransaction.claimRewardsData = data;
tokenId(tokenId: string): this {
this.claimRewardsTransaction.tokenId = tokenId;
return this;
}

/**
* Validates the claim rewards data.
*
* @param {ClaimRewardsData} data - The claim rewards data to validate
* 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.
*/
private validateClaimRewardsData(data: ClaimRewardsData): void {
if (!data) {
throw new Error('Claim rewards data is required');
}

if (!data.tokenId) {
throw new Error('Token ID is required');
}

// Validate tokenId is a valid number string
if (!/^\d+$/.test(data.tokenId)) {
throw new Error('Token ID must be a valid number string');
}
protected isValidTransactionClauses(clauses: TransactionClause[]): boolean {
try {
if (!clauses || !Array.isArray(clauses) || clauses.length === 0) {
return false;
}

if (data.claimBaseRewards !== undefined && typeof data.claimBaseRewards !== 'boolean') {
throw new Error('claimBaseRewards must be a boolean');
}
const clause = clauses[0];
if (!clause.to || !utils.isValidAddress(clause.to)) {
return false;
}

if (data.claimStakingRewards !== undefined && typeof data.claimStakingRewards !== 'boolean') {
throw new Error('claimStakingRewards must be a boolean');
}
// Ensure value is '0x0', '0', or 0
if (!['0x0', '0', 0].includes(clause.value)) {
return false;
}

// 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 (clause.data && clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) {
return true;
}

if (!claimBase && !claimStaking) {
throw new Error('At least one type of rewards (base or staking) must be claimed');
return false;
} catch (e) {
return false;
}
}

Expand All @@ -144,14 +112,10 @@ export class ClaimRewardsBuilder extends TransactionBuilder {
throw new Error('transaction not defined');
}

const claimData = transaction.claimRewardsData;
assert(claimData, 'Claim rewards data is required');
assert(claimData.tokenId, 'Token ID is required');
assert(transaction.tokenId, 'Token ID is required');
assert(transaction.stakingContractAddress, 'Staking contract address is required');

// Validate tokenId is a valid number string
if (!/^\d+$/.test(claimData.tokenId)) {
throw new Error('Token ID must be a valid number string');
}
this.validateAddress({ address: transaction.stakingContractAddress });
}

/** @inheritdoc */
Expand Down
5 changes: 1 addition & 4 deletions modules/sdk-coin-vet/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ export interface VetParseTransactionOptions extends ParseTransactionOptions {

export interface ClaimRewardsData {
tokenId: string;
delegationContractAddress?: string;
stargateNftAddress?: string;
claimBaseRewards?: boolean;
claimStakingRewards?: boolean;
stakingContractAddress?: string;
}

export type RecoverOptions = {
Expand Down
Loading